move alerts ui to sheet component

This commit is contained in:
henrygd
2025-08-22 19:28:00 -04:00
parent 684d92c497
commit c158b1aeeb
8 changed files with 272 additions and 192 deletions

View File

@@ -17,7 +17,7 @@ import { $publicKey, pb } from "@/lib/stores"
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils" import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react" import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react" import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $router, basePath, Link, navigate } from "./router" import { $router, basePath, Link, navigate } from "./router"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons" import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
@@ -122,151 +122,154 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
} }
} }
return ( return useMemo(
<DialogContent () => (
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg" <DialogContent
onCloseAutoFocus={() => { className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
setHostValue(system?.host ?? "") onCloseAutoFocus={() => {
}} setHostValue(system?.host ?? "")
> }}
<Tabs defaultValue={tab} onValueChange={setTab}> >
<DialogHeader> <Tabs defaultValue={tab} onValueChange={setTab}>
<DialogTitle className="mb-2"> <DialogHeader>
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>} <DialogTitle className="mb-2">
</DialogTitle> {system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
<TabsList className="grid w-full grid-cols-2"> </DialogTitle>
<TabsTrigger value="docker">Docker</TabsTrigger> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="binary"> <TabsTrigger value="docker">Docker</TabsTrigger>
<Trans>Binary</Trans> <TabsTrigger value="binary">
</TabsTrigger> <Trans>Binary</Trans>
</TabsList> </TabsTrigger>
</DialogHeader> </TabsList>
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */} </DialogHeader>
<TabsContent value="docker" tabIndex={-1}> {/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full"> <TabsContent value="docker" tabIndex={-1}>
<Trans> <DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
Copy the <Trans>
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent Copy the
below, or register agents automatically with a{" "} <code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
<Link below, or register agents automatically with a{" "}
onClick={() => setOpen(false)} <Link
href={getPagePath($router, "settings", { name: "tokens" })} onClick={() => setOpen(false)}
className="link" href={getPagePath($router, "settings", { name: "tokens" })}
> className="link"
universal token >
</Link> universal token
. </Link>
</Trans> .
</DialogDescription> </Trans>
</TabsContent> </DialogDescription>
{/* Binary */} </TabsContent>
<TabsContent value="binary" tabIndex={-1}> {/* Binary */}
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full"> <TabsContent value="binary" tabIndex={-1}>
<Trans> <DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
Copy the installation command for the agent below, or register agents automatically with a{" "} <Trans>
<Link Copy the installation command for the agent below, or register agents automatically with a{" "}
onClick={() => { <Link
setOpen(false) onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
<form onSubmit={handleSubmit as any}>
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
<Label htmlFor="name" className="xs:text-end">
<Trans>Name</Trans>
</Label>
<Input id="name" name="name" defaultValue={system?.name} required />
<Label htmlFor="host" className="xs:text-end">
<Trans>Host / IP</Trans>
</Label>
<Input
id="host"
name="host"
value={hostValue}
required
onChange={(e) => {
setHostValue(e.target.value)
}} }}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
<form onSubmit={handleSubmit as any}>
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
<Label htmlFor="name" className="xs:text-end">
<Trans>Name</Trans>
</Label>
<Input id="name" name="name" defaultValue={system?.name} required />
<Label htmlFor="host" className="xs:text-end">
<Trans>Host / IP</Trans>
</Label>
<Input
id="host"
name="host"
value={hostValue}
required
onChange={(e) => {
setHostValue(e.target.value)
}}
/>
<Label htmlFor="port" className={cn("xs:text-end", isUnixSocket && "hidden")}>
<Trans>Port</Trans>
</Label>
<Input
ref={port}
name="port"
id="port"
defaultValue={system?.port || "45876"}
required={!isUnixSocket}
className={cn(isUnixSocket && "hidden")}
/>
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
</Label>
<InputCopy value={publicKey} id="pkey" name="pkey" />
<Label htmlFor="tkn" className="xs:text-end whitespace-pre">
<Trans>Token</Trans>
</Label>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/> />
</TabsContent> <Label htmlFor="port" className={cn("xs:text-end", isUnixSocket && "hidden")}>
{/* Binary */} <Trans>Port</Trans>
<TabsContent value="binary" className="contents"> </Label>
<CopyButton <Input
text={t`Copy Linux command`} ref={port}
icon={<TuxIcon className="size-4" />} name="port"
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)} id="port"
dropdownItems={[ defaultValue={system?.port || "45876"}
{ required={!isUnixSocket}
text: t({ message: "Homebrew command", context: "Button to copy install command" }), className={cn(isUnixSocket && "hidden")}
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]}
/> />
</TabsContent> <Label htmlFor="pkey" className="xs:text-end whitespace-pre">
{/* Save */} <Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
<Button>{system ? <Trans>Save system</Trans> : <Trans>Add system</Trans>}</Button> </Label>
</DialogFooter> <InputCopy value={publicKey} id="pkey" name="pkey" />
</form> <Label htmlFor="tkn" className="xs:text-end whitespace-pre">
</Tabs> <Trans>Token</Trans>
</DialogContent> </Label>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" className="contents">
<CopyButton
text={t`Copy Linux command`}
icon={<TuxIcon className="size-4" />}
onClick={async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]}
/>
</TabsContent>
{/* Save */}
<Button>{system ? <Trans>Save system</Trans> : <Trans>Add system</Trans>}</Button>
</DialogFooter>
</form>
</Tabs>
</DialogContent>
),
[]
) )
} }

View File

@@ -2,56 +2,35 @@ import { t } from "@lingui/core/macro"
import { memo, useMemo, useState } from "react" import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $alerts } from "@/lib/stores" import { $alerts } from "@/lib/stores"
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"
import { BellIcon } from "lucide-react" import { BellIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
import { AlertDialogContent } from "./alerts-dialog" import { AlertDialogContent } from "./alerts-sheet"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) { export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const alerts = useStore($alerts) const alerts = useStore($alerts)
const hasSystemAlert = alerts[system.id]?.size > 0 const hasSystemAlert = alerts[system.id]?.size > 0
return useMemo( return useMemo(
() => ( () => (
<Dialog> <Sheet>
<DialogTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} onClick={() => setOpened(true)}> <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon <BellIcon
className={cn("h-[1.2em] w-[1.2em]", { className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
"fill-primary": hasSystemAlert, "fill-primary": hasSystemAlert,
})} })}
/> />
</Button> </Button>
</DialogTrigger> </SheetTrigger>
<DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-148"> <SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
{opened && <AlertDialogContent system={system} />} {opened && <AlertDialogContent system={system} />}
</DialogContent> </SheetContent>
</Dialog> </Sheet>
), ),
[opened, hasSystemAlert] [opened, hasSystemAlert]
) )
// return useMemo(
// () => (
// <Sheet>
// <SheetTrigger asChild>
// <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
// <BellIcon
// className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
// "fill-primary": hasAlert,
// })}
// />
// </Button>
// </SheetTrigger>
// <SheetContent className="max-h-full overflow-auto w-[35em] p-4 sm:p-5">
// {opened && <AlertDialogContent system={system} />}
// </SheetContent>
// </Sheet>
// ),
// [opened, hasAlert]
// )
}) })

View File

@@ -1,4 +1,4 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro"
import { useState, lazy, Suspense } from "react" import { useState, lazy, Suspense } from "react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { import {

View File

@@ -45,7 +45,7 @@ export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
window.open(href, "_blank") window.open(href, "_blank")
} else { } else {
$router.open(href) navigate(href)
props.onClick?.(e) props.onClick?.(e)
} }
}} }}

View File

@@ -111,21 +111,18 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
Icon: ServerIcon, Icon: ServerIcon,
cell: (info) => { cell: (info) => {
const { name } = info.row.original const { name } = info.row.original
return useMemo( return (
() => ( <>
<> <span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5"> <IndicatorDot system={info.row.original} />
<IndicatorDot system={info.row.original} /> {name}
{name} </span>
</span> <Link
<Link href={getPagePath($router, "system", { name })}
href={getPagePath($router, "system", { name })} className="inset-0 absolute size-full"
className="inset-0 absolute size-full" aria-label={name}
aria-label={name} ></Link>
></Link> </>
</>
),
[name]
) )
}, },
header: sortableHeader, header: sortableHeader,

View File

@@ -0,0 +1,101 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in duration-500 isolate data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-[400ms]",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }

View File

@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs", "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer",
className className
)} )}
{...props} {...props}