react refactoring for better performance with lots of systems

This commit is contained in:
henrygd
2025-03-13 02:16:54 -04:00
parent ae22334645
commit d36b8369cc
6 changed files with 553 additions and 492 deletions

View File

@@ -19,17 +19,15 @@ import {
CommandSeparator, CommandSeparator,
CommandShortcut, CommandShortcut,
} from "@/components/ui/command" } from "@/components/ui/command"
import { useEffect } from "react" import { memo, useEffect, useMemo } from "react"
import { useStore } from "@nanostores/react"
import { $systems } from "@/lib/stores" import { $systems } from "@/lib/stores"
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils" import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
import { $router, basePath, navigate } from "./router" import { $router, basePath, navigate } from "./router"
import { Trans, t } from "@lingui/macro" import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const systems = useStore($systems)
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -40,157 +38,160 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
return listen(document, "keydown", down) return listen(document, "keydown", down)
}, [open, setOpen]) }, [open, setOpen])
return ( return useMemo(() => {
<CommandDialog open={open} onOpenChange={setOpen}> const systems = $systems.get()
<CommandInput placeholder={t`Search for systems or settings...`} /> return (
<CommandList> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandEmpty> <CommandInput placeholder={t`Search for systems or settings...`} />
<Trans>No results found.</Trans> <CommandList>
</CommandEmpty> <CommandEmpty>
{systems.length > 0 && ( <Trans>No results found.</Trans>
<> </CommandEmpty>
<CommandGroup> {systems.length > 0 && (
{systems.map((system) => ( <>
<CommandGroup>
{systems.map((system) => (
<CommandItem
key={system.id}
onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name }))
setOpen(false)
}}
>
<Server className="me-2 h-4 w-4" />
<span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator className="mb-1.5" />
</>
)}
<CommandGroup heading={t`Pages / Settings`}>
<CommandItem
keywords={["home"]}
onSelect={() => {
navigate(basePath)
setOpen(false)
}}
>
<LayoutDashboard className="me-2 h-4 w-4" />
<span>
<Trans>Dashboard</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))
setOpen(false)
}}
>
<SettingsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["alerts"]}
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "notifications" }))
setOpen(false)
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>Notifications</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<span>
<Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator className="mb-1.5" />
<CommandGroup heading={t`Admin`}>
<CommandItem <CommandItem
key={system.id} keywords={["pocketbase"]}
onSelect={() => { onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name }))
setOpen(false) setOpen(false)
window.open("/_/", "_blank")
}} }}
> >
<Server className="me-2 h-4 w-4" /> <UsersIcon className="me-2 h-4 w-4" />
<span>{system.name}</span> <span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut> <Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
))} <CommandItem
</CommandGroup> onSelect={() => {
<CommandSeparator className="mb-1.5" /> setOpen(false)
</> window.open("/_/#/logs", "_blank")
)} }}
<CommandGroup heading={t`Pages / Settings`}> >
<CommandItem <LogsIcon className="me-2 h-4 w-4" />
keywords={["home"]} <span>
onSelect={() => { <Trans>Logs</Trans>
navigate(basePath) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<LayoutDashboard className="me-2 h-4 w-4" /> </CommandItem>
<span> <CommandItem
<Trans>Dashboard</Trans> onSelect={() => {
</span> setOpen(false)
<CommandShortcut> window.open("/_/#/settings/backups", "_blank")
<Trans>Page</Trans> }}
</CommandShortcut> >
</CommandItem> <DatabaseBackupIcon className="me-2 h-4 w-4" />
<CommandItem <span>
onSelect={() => { <Trans>Backups</Trans>
navigate(getPagePath($router, "settings", { name: "general" })) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<SettingsIcon className="me-2 h-4 w-4" /> </CommandItem>
<span> <CommandItem
<Trans>Settings</Trans> keywords={["email"]}
</span> onSelect={() => {
<CommandShortcut> setOpen(false)
<Trans>Settings</Trans> window.open("/_/#/settings/mail", "_blank")
</CommandShortcut> }}
</CommandItem> >
<CommandItem <MailIcon className="me-2 h-4 w-4" />
keywords={["alerts"]} <span>
onSelect={() => { <Trans>SMTP settings</Trans>
navigate(getPagePath($router, "settings", { name: "notifications" })) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<MailIcon className="me-2 h-4 w-4" /> </CommandItem>
<span> </CommandGroup>
<Trans>Notifications</Trans> </>
</span> )}
<CommandShortcut> </CommandList>
<Trans>Settings</Trans> </CommandDialog>
</CommandShortcut> )
</CommandItem> }, [open])
<CommandItem })
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<span>
<Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator className="mb-1.5" />
<CommandGroup heading={t`Admin`}>
<CommandItem
keywords={["pocketbase"]}
onSelect={() => {
setOpen(false)
window.open("/_/", "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
<span>
<Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/logs", "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Logs</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/backups", "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
<span>
<Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["email"]}
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/mail", "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}

View File

@@ -1,6 +1,6 @@
import { Suspense, lazy, useEffect, useMemo } from "react" import { Suspense, lazy, memo, useEffect, useMemo } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { $alerts, $hubVersion, $systems, pb } from "@/lib/stores" import { $alerts, $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react" import { GithubIcon } from "lucide-react"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
@@ -8,17 +8,17 @@ import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from "@/types" import { AlertRecord, SystemRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { Plural, t, Trans } from "@lingui/macro" import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
const SystemsTable = lazy(() => import("../systems-table/systems-table")) const SystemsTable = lazy(() => import("../systems-table/systems-table"))
export default function Home() { export const Home = memo(() => {
const hubVersion = useStore($hubVersion)
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($systems) const systems = useStore($systems)
const { t } = useLingui()
let alertsKey = ""
const activeAlerts = useMemo(() => { const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => { const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo const active = alert.triggered && alert.name in alertInfo
@@ -26,14 +26,17 @@ export default function Home() {
return false return false
} }
alert.sysname = systems.find((system) => system.id === alert.system)?.name alert.sysname = systems.find((system) => system.id === alert.system)?.name
alertsKey += alert.id
return true return true
}) })
return activeAlerts return activeAlerts
}, [alerts]) }, [systems, alerts])
useEffect(() => { useEffect(() => {
document.title = t`Dashboard` + " / Beszel" document.title = t`Dashboard` + " / Beszel"
}, [t])
useEffect(() => {
// make sure we have the latest list of systems // make sure we have the latest list of systems
updateSystemList() updateSystemList()
@@ -41,7 +44,6 @@ export default function Home() {
pb.collection<SystemRecord>("systems").subscribe("*", (e) => { pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
updateRecordList(e, $systems) updateRecordList(e, $systems)
}) })
// todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => { pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
updateRecordList(e, $alerts) updateRecordList(e, $alerts)
}) })
@@ -51,56 +53,15 @@ export default function Home() {
} }
}, []) }, [])
return ( return useMemo(
<> () => (
{/* show active alerts */} <>
{activeAlerts.length > 0 && ( {/* show active alerts */}
<Card className="mb-4"> {activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <Suspense>
<div className="px-2 sm:px-1"> <SystemsTable />
<CardTitle> </Suspense>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Suspense>
<SystemsTable />
</Suspense>
{hubVersion && (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
<a <a
href="https://github.com/henrygd/beszel" href="https://github.com/henrygd/beszel"
@@ -115,10 +76,56 @@ export default function Home() {
target="_blank" target="_blank"
className="text-muted-foreground hover:text-foreground duration-75" className="text-muted-foreground hover:text-foreground duration-75"
> >
Beszel {hubVersion} Beszel {globalThis.BESZEL.HUB_VERSION}
</a> </a>
</div> </div>
)} </>
</> ),
[alertsKey]
) )
} })
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
return (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
})

View File

@@ -10,6 +10,8 @@ import {
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
HeaderContext, HeaderContext,
Row,
Table as TableType,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -61,14 +63,13 @@ import {
PenBoxIcon, PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react" import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $hubVersion, $systems, pb } from "@/lib/stores" import { $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils" import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import AlertsButton from "../alerts/alert-button" import AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router" import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons" import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons"
import { Trans, t } from "@lingui/macro" import { useLingui, Trans } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { ClassValue } from "clsx" import { ClassValue } from "clsx"
@@ -103,62 +104,66 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) { function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context const { column } = context
// @ts-ignore
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="h-9 px-3 flex" className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
{/* @ts-ignore */} {Icon && <Icon className="me-2 size-4" />}
{column.columnDef.icon && <column.columnDef.icon className="me-2 size-4" />} {name()}
{column.id} {hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
{/* @ts-ignore */}
{column.columnDef.hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button> </Button>
) )
} }
export default function SystemsTable() { export default function SystemsTable() {
const data = useStore($systems) const data = useStore($systems)
const hubVersion = useStore($hubVersion) const { i18n, t } = useLingui()
const [filter, setFilter] = useState<string>() const [filter, setFilter] = useState<string>()
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }]) const [sorting, setSorting] = useState<SortingState>([{ id: "system", desc: false }])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {}) const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid") const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
const { i18n } = useLingui()
const locale = i18n.locale
useEffect(() => { useEffect(() => {
if (filter !== undefined) { if (filter !== undefined) {
table.getColumn(t`System`)?.setFilterValue(filter) table.getColumn("system")?.setFilterValue(filter)
} }
}, [filter]) }, [filter])
const columns = useMemo(() => { const columnDefs = useMemo(() => {
// Create status translations for filtering
const statusTranslations = { const statusTranslations = {
"up": t`Up`.toLowerCase(), up: () => t`Up`.toLowerCase(),
"down": t`Down`.toLowerCase(), down: () => t`Down`.toLowerCase(),
"paused": t`Paused`.toLowerCase() paused: () => t`Paused`.toLowerCase(),
}; }
return [ return [
{ {
// size: 200, // size: 200,
size: 200, size: 200,
minSize: 0, minSize: 0,
accessorKey: "name", accessorKey: "name",
id: t`System`, id: "system",
name: () => t`System`,
filterFn: (row, _, filterVal) => { filterFn: (row, _, filterVal) => {
const filterLower = filterVal.toLowerCase(); const filterLower = filterVal.toLowerCase()
const { name, status } = row.original; const { name, status } = row.original
// Check if the filter matches the name or status for this row // Check if the filter matches the name or status for this row
if (name.toLowerCase().includes(filterLower) || statusTranslations[status as keyof typeof statusTranslations]?.includes(filterLower)) { if (
return true; name.toLowerCase().includes(filterLower) ||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
) {
return true
} }
return false; return false
}, },
enableHiding: false, enableHiding: false,
icon: ServerIcon, Icon: ServerIcon,
cell: (info) => ( cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5"> <span className="flex gap-0.5 items-center text-base md:pe-5">
<IndicatorDot system={info.row.original} /> <IndicatorDot system={info.row.original} />
@@ -177,43 +182,48 @@ export default function SystemsTable() {
}, },
{ {
accessorKey: "info.cpu", accessorKey: "info.cpu",
id: t`CPU`, id: "cpu",
name: () => t`CPU`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: CpuIcon, Icon: CpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.mp", accessorKey: "info.mp",
id: t`Memory`, id: "memory",
name: () => t`Memory`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: MemoryStickIcon, Icon: MemoryStickIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.dp", accessorKey: "info.dp",
id: t`Disk`, id: "disk",
name: () => t`Disk`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: HardDriveIcon, Icon: HardDriveIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: (originalRow) => originalRow.info.g, accessorFn: (originalRow) => originalRow.info.g,
id: "GPU", id: "gpu",
name: () => t`GPU`,
invertSorting: true, invertSorting: true,
sortUndefined: -1, sortUndefined: -1,
cell: CellFormatter, cell: CellFormatter,
icon: GpuIcon, Icon: GpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: (originalRow) => originalRow.info.b || 0, accessorFn: (originalRow) => originalRow.info.b || 0,
id: t`Net`, id: "net",
name: () => t`Net`,
invertSorting: true, invertSorting: true,
size: 50, size: 50,
icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -230,15 +240,13 @@ export default function SystemsTable() {
}, },
{ {
accessorFn: (originalRow) => originalRow.info.dt, accessorFn: (originalRow) => originalRow.info.dt,
id: t({ id: "temp",
message: "Temp", name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
comment: "Temperature label in systems table",
}),
invertSorting: true, invertSorting: true,
sortUndefined: -1, sortUndefined: -1,
size: 50, size: 50,
hideSort: true, hideSort: true,
icon: ThermometerIcon, Icon: ThermometerIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -258,15 +266,16 @@ export default function SystemsTable() {
}, },
{ {
accessorKey: "info.v", accessorKey: "info.v",
id: t`Agent`, id: "agent",
name: () => t`Agent`,
invertSorting: true, invertSorting: true,
size: 50, size: 50,
icon: WifiIcon, Icon: WifiIcon,
hideSort: true, hideSort: true,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const version = info.getValue() as string const version = info.getValue() as string
if (!version || !hubVersion) { if (!version) {
return null return null
} }
const system = info.row.original const system = info.row.original
@@ -280,7 +289,7 @@ export default function SystemsTable() {
system={system} system={system}
className={ className={
(system.status !== "up" && "bg-primary/30") || (system.status !== "up" && "bg-primary/30") ||
(version === hubVersion && "bg-green-500") || (version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
"bg-yellow-500" "bg-yellow-500"
} }
/> />
@@ -290,7 +299,9 @@ export default function SystemsTable() {
}, },
}, },
{ {
id: t({ message: "Actions", comment: "Table column" }), id: "actions",
// @ts-ignore
name: () => t({ message: "Actions", comment: "Table column" }),
size: 50, size: 50,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end items-center gap-1"> <div className="flex justify-end items-center gap-1">
@@ -300,11 +311,11 @@ export default function SystemsTable() {
), ),
}, },
] as ColumnDef<SystemRecord>[] ] as ColumnDef<SystemRecord>[]
}, [hubVersion, i18n.locale]) }, [])
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns: columnDefs,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
@@ -318,15 +329,17 @@ export default function SystemsTable() {
}, },
defaultColumn: { defaultColumn: {
minSize: 0, minSize: 0,
size: Number.MAX_SAFE_INTEGER, size: 900,
maxSize: Number.MAX_SAFE_INTEGER, maxSize: 900,
}, },
}) })
const rows = table.getRowModel().rows const rows = table.getRowModel().rows
const columns = table.getAllColumns()
return ( const visibleColumns = table.getVisibleLeafColumns()
<Card> // TODO: hiding temp then gpu messes up table headers
const CardHead = useMemo(() => {
return (
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-5 w-full items-end"> <div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1"> <div className="px-2 sm:px-1">
@@ -377,8 +390,8 @@ export default function SystemsTable() {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-1 pb-1"> <div className="px-1 pb-1">
{table.getAllColumns().map((column) => { {columns.map((column) => {
if (column.id === t`Actions` || !column.getCanSort()) return null if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span> let Icon = <span className="w-6"></span>
// if current sort column, show sort direction // if current sort column, show sort direction
if (sorting[0]?.id === column.id) { if (sorting[0]?.id === column.id) {
@@ -397,7 +410,8 @@ export default function SystemsTable() {
key={column.id} key={column.id}
> >
{Icon} {Icon}
{column.id} {/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
@@ -411,8 +425,7 @@ export default function SystemsTable() {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-1.5 pb-1"> <div className="px-1.5 pb-1">
{table {columns
.getAllColumns()
.filter((column) => column.getCanHide()) .filter((column) => column.getCanHide())
.map((column) => { .map((column) => {
return ( return (
@@ -422,7 +435,8 @@ export default function SystemsTable() {
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(!!value)}
> >
{column.id} {/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
) )
})} })}
@@ -434,128 +448,24 @@ export default function SystemsTable() {
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
)
}, [visibleColumns.length, sorting, viewMode, locale])
return (
<Card>
{CardHead}
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2"> <div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
{viewMode === "table" ? ( {viewMode === "table" ? (
// table layout // table layout
<div className="rounded-md border overflow-hidden"> <div className="rounded-md border overflow-hidden">
<Table> <AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.original.id}
data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity", {
"opacity-50": row.original.status === "paused",
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(getPagePath($router, "system", { name: row.original.name }))
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
}}
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
) : ( ) : (
// grid layout // grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{table.getRowModel().rows?.length ? ( {rows?.length ? (
table.getRowModel().rows.map((row) => { rows.map((row) => {
const system = row.original return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
const { status } = system
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn(t`Actions`)?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
return (
<div key={column.id} className="flex items-center gap-3">
{/* @ts-ignore */}
{column.columnDef?.icon && (
// @ts-ignore
<column.columnDef.icon className="size-4 text-muted-foreground" />
)}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{column.id}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
}) })
) : ( ) : (
<div className="col-span-full text-center py-8"> <div className="col-span-full text-center py-8">
@@ -569,6 +479,247 @@ export default function SystemsTable() {
) )
} }
const AllSystemsTable = memo(
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
return (
<Table>
<SystemsTableHead table={table} colLength={colLength} />
<TableBody>
{rows.length ? (
rows.map((row) => (
<SystemTableRow key={row.original.id} row={row} length={rows.length} colLength={colLength} />
))
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
)
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
const { i18n } = useLingui()
return useMemo(() => {
return (
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
)
}, [i18n.locale, colLength])
}
const SystemTableRow = memo(
({ row, length, colLength }: { row: Row<SystemRecord>; length: number; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<TableRow
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity", {
"opacity-50": system.status === "paused",
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(getPagePath($router, "system", { name: system.name }))
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
}}
className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}, [system, colLength, t])
}
)
const SystemCard = memo(
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": system.status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
// @ts-ignore
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
return (
<div key={column.id} className="flex items-center gap-3">
{Icon && <Icon className="size-4 text-muted-foreground" />}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{name()}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
}, [system, colLength, t])
}
)
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { t } = useLingui()
return useMemo(() => {
const { id, status, host, name } = system
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}, [system.id, t, deleteOpen, editOpen])
})
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= { className ||= {
"bg-green-500": system.status === "up", "bg-green-500": system.status === "up",
@@ -583,99 +734,3 @@ function IndicatorDot({ system, className }: { system: SystemRecord; className?:
/> />
) )
} }
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { id, status, host, name } = system
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
})

View File

@@ -74,6 +74,7 @@
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
overflow-anchor: none;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;

View File

@@ -18,9 +18,6 @@ export const $alerts = atom([] as AlertRecord[])
/** SSH public key */ /** SSH public key */
export const $publicKey = atom("") export const $publicKey = atom("")
/** Beszel hub version */
export const $hubVersion = atom("")
/** Chart time period */ /** Chart time period */
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes> export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>

View File

@@ -1,11 +1,11 @@
import "./index.css" import "./index.css"
// import { Suspense, lazy, useEffect, StrictMode } from "react" // import { Suspense, lazy, useEffect, StrictMode } from "react"
import { Suspense, lazy, useEffect } from "react" import { Suspense, lazy, memo, useEffect } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import Home from "./components/routes/home.tsx" import { Home } from "./components/routes/home.tsx"
import { ThemeProvider } from "./components/theme-provider.tsx" import { ThemeProvider } from "./components/theme-provider.tsx"
import { DirectionProvider } from "@radix-ui/react-direction" import { DirectionProvider } from "@radix-ui/react-direction"
import { $authenticated, $systems, pb, $publicKey, $hubVersion, $copyContent, $direction } from "./lib/stores.ts" import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts" import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { Toaster } from "./components/ui/toaster.tsx" import { Toaster } from "./components/ui/toaster.tsx"
@@ -14,13 +14,14 @@ import SystemDetail from "./components/routes/system.tsx"
import Navbar from "./components/navbar.tsx" import Navbar from "./components/navbar.tsx"
import { I18nProvider } from "@lingui/react" import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core" import { i18n } from "@lingui/core"
import "@/lib/i18n.ts"
// const ServerDetail = lazy(() => import('./components/routes/system.tsx')) // const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
const LoginPage = lazy(() => import("./components/login/login.tsx")) const LoginPage = lazy(() => import("./components/login/login.tsx"))
const CopyToClipboardDialog = lazy(() => import("./components/copy-to-clipboard.tsx")) const CopyToClipboardDialog = lazy(() => import("./components/copy-to-clipboard.tsx"))
const Settings = lazy(() => import("./components/routes/settings/layout.tsx")) const Settings = lazy(() => import("./components/routes/settings/layout.tsx"))
const App = () => { const App = memo(() => {
const page = useStore($router) const page = useStore($router)
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const systems = useStore($systems) const systems = useStore($systems)
@@ -33,7 +34,6 @@ const App = () => {
// get version / public key // get version / public key
pb.send("/api/beszel/getkey", {}).then((data) => { pb.send("/api/beszel/getkey", {}).then((data) => {
$publicKey.set(data.key) $publicKey.set(data.key)
$hubVersion.set(data.v)
}) })
// get servers / alerts / settings // get servers / alerts / settings
updateUserSettings() updateUserSettings()
@@ -74,7 +74,7 @@ const App = () => {
</Suspense> </Suspense>
) )
} }
} })
const Layout = () => { const Layout = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)