import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { type Column, type ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, type SortingState, useReactTable, } from "@tanstack/react-table" import { ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, GitCompareArrowsIcon, PackageCheckIcon, PackageIcon, PackageOpenIcon, ShieldAlertIcon, XIcon, } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { Badge, type BadgeProps } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { pb } from "@/lib/api" import { classifyVersionChange, type VersionChange } from "@/lib/package-updates" import { cn, formatShortDate, useBrowserStorage } from "@/lib/utils" import type { PackageUpdate, PackageUpdates } from "@/types" interface PackageUpdateRow extends PackageUpdate { change: VersionChange } /** Sort order of version changes, so ascending puts major first. */ const changeRank: Record = { major: 0, minor: 1, patch: 2, revision: 3, other: 4 } const changeVariant: Record = { major: "danger", minor: "warning", patch: "success", revision: "secondary", other: "outline", } const pageSizes = [10, 20, 50, 100, 200] function changeLabel(change: VersionChange) { switch (change) { case "major": return t({ message: "Major", context: "Version change" }) case "minor": return t({ message: "Minor", context: "Version change" }) case "patch": return t({ message: "Patch", context: "Version change" }) case "revision": return t({ message: "Revision", context: "Version change" }) default: return t({ message: "Other", context: "Version change" }) } } function HeaderButton({ column, name, Icon, }: { column: Column name: string Icon: React.ElementType }) { const isSorted = column.getIsSorted() return ( ) } function getColumns(securityKnown: boolean): ColumnDef[] { const columns: ColumnDef[] = [ { id: "name", accessorFn: (pkg) => pkg.name, sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), header: ({ column }) => , cell: ({ getValue }) => {getValue() as string}, }, { id: "current", accessorFn: (pkg) => pkg.current ?? "", enableSorting: false, header: () => ( Current ), cell: ({ getValue }) => ( {(getValue() as string) || "-"} ), }, { id: "available", accessorFn: (pkg) => pkg.available, enableSorting: false, header: () => ( Available ), cell: ({ getValue }) => {getValue() as string}, }, ] if (securityKnown) { columns.push({ id: "security", accessorFn: (pkg) => (pkg.security ? 1 : 0), header: ({ column }) => , cell: ({ row }) => row.original.security ? ( Security ) : null, }) } // Change is always the last column, pinned to the right edge of the table columns.push({ id: "change", accessorFn: (pkg) => changeRank[pkg.change], header: ({ column }) => , cell: ({ row }) => ( {changeLabel(row.original.change)} ), }) return columns } /** * Lists pending package updates reported by the agent. The agent caches the result of * its background check, so this refetches only when the update counts change. */ export default function PackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) { const [data, setData] = useState(null) const [error, setError] = useState(null) const [sorting, setSorting] = useState([{ id: "name", desc: false }]) const [globalFilter, setGlobalFilter] = useState("") // Store page size preference in local storage const [pageSize, setPageSize] = useBrowserStorage("pu-page-size", pageSizes[1], sessionStorage) const [pageIndex, setPageIndex] = useState(0) const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]) useEffect(() => { let cancelled = false pb.send("/api/beszel/package-updates", { query: { system: systemId } }) .then((result) => { if (cancelled) return setData(result) setError(null) }) .catch((err) => { if (cancelled) return setError(err?.message || t`Failed to load package updates`) }) return () => { cancelled = true } }, [systemId, counts]) const rows = useMemo( () => (data?.packages ?? []).map((pkg) => ({ ...pkg, change: classifyVersionChange(pkg.current, pkg.available) })), [data] ) const securityKnown = !!data?.securityKnown const columns = useMemo(() => getColumns(securityKnown), [securityKnown]) const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, onPaginationChange: (updater) => { const next = typeof updater === "function" ? updater(pagination) : updater setPageIndex(next.pageIndex) setPageSize(next.pageSize) }, state: { sorting, globalFilter, pagination }, globalFilterFn: (row, _columnId, filterValue: string) => { const pkg = row.original const searchString = `${pkg.name} ${pkg.current ?? ""} ${pkg.available} ${changeLabel(pkg.change)}`.toLowerCase() return filterValue .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) if (!data && !error) { return null } const securityCount = rows.filter((pkg) => pkg.security).length const tableRows = table.getRowModel().rows return (
Package Updates {data?.manager && ( <> {data.manager} )} Total: {rows.length} {securityKnown && ( <> Security: {securityCount} )} {!!data?.checkedAt && ( <> Checked {formatShortDate(new Date(data.checkedAt * 1000).toISOString())} )}
{rows.length > 0 && (
setGlobalFilter(event.target.value)} className="px-4 w-full max-w-full md:w-64" /> {globalFilter && ( )}
)}
{error ? (

{error}

) : (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} {tableRows.length ? ( tableRows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( {rows.length ? No results. : Up to date} )}
)} {!error && rows.length > pageSizes[0] && (
Page {pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
)}
) }