import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { type ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, type Row, type SortingState, type Table as TableType, useReactTable, type VisibilityState, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { memo, RefObject, useEffect, useRef, useState } from "react" import { Input } from "@/components/ui/input" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { pb } from "@/lib/api" import type { ContainerRecord } from "@/types" import { containerChartCols } from "@/components/containers-table/containers-table-columns" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { cn, useBrowserStorage } from "@/lib/utils" import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet" import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" import { Button } from "@/components/ui/button" import { $allSystemsById } from "@/lib/stores" import { MaximizeIcon, RefreshCwIcon } from "lucide-react" import { Separator } from "../ui/separator" import { Link } from "../router" import { listenKeys } from "nanostores" const syntaxTheme = "github-dark-dimmed" export default function ContainersTable({ systemId }: { systemId?: string }) { const [data, setData] = useState([]) const [sorting, setSorting] = useBrowserStorage( `sort-c-${systemId ? 1 : 0}`, [{ id: systemId ? "name" : "system", desc: false }], sessionStorage ) const [columnFilters, setColumnFilters] = useState([]) const [columnVisibility, setColumnVisibility] = useState({}) const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState("") useEffect(() => { const pbOptions = { fields: "id,name,cpu,memory,net,health,status,system,updated", } const fetchData = (lastXMs: number) => { const updated = Date.now() - lastXMs let filter: string if (systemId) { filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated }) } else { filter = pb.filter("updated > {:updated}", { updated }) } pb.collection("containers") .getList(0, 2000, { ...pbOptions, filter, }) .then(({ items }) => setData((curItems) => { const containerIds = new Set(items.map(item => item.id)) const now = Date.now() for (const item of curItems) { if (!containerIds.has(item.id) && now - item.updated < 70_000) { items.push(item) } } return items })) } // initial load fetchData(70_000) // if no systemId, poll every 10 seconds if (!systemId) { // poll every 10 seconds const intervalId = setInterval(() => fetchData(10_500), 10_000) // clear interval on unmount return () => clearInterval(intervalId) } // if systemId, fetch containers after the system is updated return listenKeys($allSystemsById, [systemId], (_newSystems) => { setTimeout(() => fetchData(1000), 100) }) }, []) const table = useReactTable({ data, columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true), getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, defaultColumn: { sortUndefined: "last", size: 100, minSize: 0, }, state: { sorting, columnFilters, columnVisibility, rowSelection, globalFilter, }, onGlobalFilterChange: setGlobalFilter, globalFilterFn: (row, _columnId, filterValue) => { const container = row.original const systemName = $allSystemsById.get()[container.system]?.name ?? "" const id = container.id ?? "" const name = container.name ?? "" const status = container.status ?? "" const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) const rows = table.getRowModel().rows const visibleColumns = table.getVisibleLeafColumns() if (!rows.length) return null return (
All Containers Click on a container to view more information.
setGlobalFilter(e.target.value)} className="ms-auto px-4 w-full max-w-full md:w-64" />
) } const AllContainersTable = memo( function AllContainersTable({ table, rows, colLength }: { table: TableType; rows: Row[]; colLength: number }) { // The virtualizer will need a reference to the scrollable container element const scrollRef = useRef(null) const activeContainer = useRef(null) const [sheetOpen, setSheetOpen] = useState(false) const openSheet = (container: ContainerRecord) => { activeContainer.current = container setSheetOpen(true) } const virtualizer = useVirtualizer({ count: rows.length, estimateSize: () => 54, getScrollElement: () => scrollRef.current, overscan: 5, }) const virtualRows = virtualizer.getVirtualItems() const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) return (
2) && "min-h-50" )} ref={scrollRef} > {/* add header height to table size */}
{rows.length ? ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] return ( ) }) ) : ( No results. )}
) } ) async function getLogsHtml(container: ContainerRecord): Promise { try { const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", { system: container.system, container: container.id, })]) return highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) } catch (error) { console.error(error) return "" } } async function getInfoHtml(container: ContainerRecord): Promise { try { let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", { system: container.system, container: container.id, })]) try { info = JSON.stringify(JSON.parse(info), null, 2) } catch (_) { } return highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) } catch (error) { console.error(error) return "" } } function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject }) { const container = activeContainer.current if (!container) return null const [logsDisplay, setLogsDisplay] = useState("") const [infoDisplay, setInfoDisplay] = useState("") const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false) const [infoFullscreenOpen, setInfoFullscreenOpen] = useState(false) const [isRefreshingLogs, setIsRefreshingLogs] = useState(false) const logsContainerRef = useRef(null) function scrollLogsToBottom() { if (logsContainerRef.current) { logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight }) } } const refreshLogs = async () => { setIsRefreshingLogs(true) const startTime = Date.now() try { const logsHtml = await getLogsHtml(container) setLogsDisplay(logsHtml) setTimeout(scrollLogsToBottom, 20) } catch (error) { console.error(error) } finally { // Ensure minimum spin duration of 800ms const elapsed = Date.now() - startTime const remaining = Math.max(0, 500 - elapsed) setTimeout(() => { setIsRefreshingLogs(false) }, remaining) } } useEffect(() => { setLogsDisplay("") setInfoDisplay(""); if (!container) return (async () => { const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) setLogsDisplay(logsHtml) setInfoDisplay(infoHtml) setTimeout(scrollLogsToBottom, 20) })() }, [container]) return ( <> {container.name} {$allSystemsById.get()[container.system]?.name ?? ""} {container.status} {container.id} {ContainerHealthLabels[container.health as ContainerHealth]}

{t`Logs`}

{t`Detail`}

) } function ContainersTableHead({ table }: { table: TableType }) { return ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ) })} ))} ) } const ContainerTableRow = memo( function ContainerTableRow({ row, virtualRow, openSheet, }: { row: Row virtualRow: VirtualItem openSheet: (container: ContainerRecord) => void }) { return ( openSheet(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) } ) function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise, isRefreshing: boolean }) { const outerContainerRef = useRef(null) useEffect(() => { if (open && logsDisplay) { // Scroll the outer container to bottom const scrollToBottom = () => { if (outerContainerRef.current) { outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight } } setTimeout(scrollToBottom, 50) } }, [open, logsDisplay]) return ( {containerName} logs
) } function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) { return ( {containerName} info
) }