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, XIcon } from "lucide-react" import { Separator } from "../ui/separator" import { $router, Link } from "../router" import { listenKeys } from "nanostores" import { getPagePath } from "@nanostores/router" const syntaxTheme = "github-dark-dimmed" export default function ContainersTable({ systemId }: { systemId?: string }) { const loadTime = Date.now() 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(() => { function fetchData(systemId?: string) { pb.collection("containers") .getList(0, 2000, { fields: "id,name,image,cpu,memory,net,health,status,system,updated", filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, }) .then( ({ items }) => items.length && setData((curItems) => { const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) const containerIds = new Set() const newItems = [] for (const item of items) { if (Math.abs(lastUpdated - item.updated) < 70_000) { containerIds.add(item.id) newItems.push(item) } } for (const item of curItems) { if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) { newItems.push(item) } } return newItems }) ) } // initial load fetchData(systemId) // if no systemId, pull system containers after every system update if (!systemId) { return $allSystemsById.listen((_value, _oldValue, systemId) => { // exclude initial load of systems if (Date.now() - loadTime > 500) { fetchData(systemId) } }) } // if systemId, fetch containers after the system is updated return listenKeys($allSystemsById, [systemId], (_newSystems) => { fetchData(systemId) }) }, []) 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 image = container.image ?? "" const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) const rows = table.getRowModel().rows const visibleColumns = table.getVisibleLeafColumns() return (
All Containers Click on a container to view more information.
setGlobalFilter(e.target.value)} className="ps-4 pe-10 w-full" /> {globalFilter && ( )}
) } 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 logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.` } 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 info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.` } 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.image} {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
) }