mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 10:19:27 +08:00
react refactoring for better performance with lots of systems
This commit is contained in:
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
@@ -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;
|
||||||
|
@@ -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>
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user