feat: edit system dialog in dashboard

This commit is contained in:
henrygd
2025-02-11 14:12:25 -05:00
parent a94cfff965
commit 6b2a9463ca
4 changed files with 211 additions and 172 deletions

View File

@@ -237,8 +237,8 @@ func (h *Hub) Run() {
oldRecord := newRecord.Original() oldRecord := newRecord.Original()
newStatus := newRecord.GetString("status") newStatus := newRecord.GetString("status")
// if system is disconnected and connection exists, remove it // if system is not up and connection exists, remove it
if newStatus == "down" || newStatus == "paused" { if newStatus != "up" {
h.deleteSystemConnection(newRecord) h.deleteSystemConnection(newRecord)
} }

View File

@@ -16,15 +16,46 @@ import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores" import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils" import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
import { i18n } from "@lingui/core" import { i18n } from "@lingui/core"
import { Trans } from "@lingui/macro" import { t, Trans } from "@lingui/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react" import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
import { MutableRefObject, useRef, useState } from "react" import { memo, MutableRefObject, useRef, useState } from "react"
import { basePath, navigate } from "./router" import { basePath, navigate } from "./router"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { SystemRecord } from "@/types"
export function AddSystemButton({ className }: { className?: string }) { export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
let opened = useRef(false)
if (open) {
opened.current = true
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</Button>
</DialogTrigger>
{opened.current && <SystemDialog setOpen={setOpen} />}
</Dialog>
)
}
/**
* SystemDialog component for adding or editing a system.
* @param {Object} props - The component props.
* @param {function} props.setOpen - Function to set the open state of the dialog.
* @param {SystemRecord} [props.system] - Optional system record for editing an existing system.
*/
export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
const port = useRef() as MutableRefObject<HTMLInputElement> const port = useRef() as MutableRefObject<HTMLInputElement>
const publicKey = useStore($publicKey) const publicKey = useStore($publicKey)
@@ -43,6 +74,7 @@ export function AddSystemButton({ className }: { className?: string }) {
PORT: ${port} PORT: ${port}
KEY: "${publicKey}"`) KEY: "${publicKey}"`)
} }
function copyDockerRun(port: string) { function copyDockerRun(port: string) {
copyToClipboard( copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e PORT=${port} henrygd/beszel-agent:latest` `docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e PORT=${port} henrygd/beszel-agent:latest`
@@ -65,7 +97,11 @@ export function AddSystemButton({ className }: { className?: string }) {
data.users = pb.authStore.record!.id data.users = pb.authStore.record!.id
try { try {
setOpen(false) setOpen(false)
await pb.collection("systems").create(data) if (system) {
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
} else {
await pb.collection("systems").create(data)
}
navigate(basePath) navigate(basePath)
// console.log(record) // console.log(record)
} catch (e) { } catch (e) {
@@ -74,138 +110,119 @@ export function AddSystemButton({ className }: { className?: string }) {
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <DialogContent className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg">
<DialogTrigger asChild> <Tabs defaultValue="docker">
<Button <DialogHeader>
variant="outline" <DialogTitle className="mb-2">
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")} {system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
> </DialogTitle>
<PlusIcon className="h-4 w-4 -ms-1" /> <TabsList className="grid w-full grid-cols-2">
<Trans> <TabsTrigger value="docker">Docker</TabsTrigger>
Add <span className="hidden sm:inline">System</span> <TabsTrigger value="binary">
</Trans> <Trans>Binary</Trans>
</Button> </TabsTrigger>
</DialogTrigger> </TabsList>
<DialogContent className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"> </DialogHeader>
<Tabs defaultValue="docker"> {/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<DialogHeader> <TabsContent value="docker" tabIndex={-1}>
<DialogTitle className="mb-2"> <DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<Trans>Add New System</Trans> <Trans>
</DialogTitle> The agent must be running on the system to connect. Copy the
<TabsList className="grid w-full grid-cols-2"> <code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
<TabsTrigger value="docker">Docker</TabsTrigger> </Trans>
<TabsTrigger value="binary"> </DialogDescription>
<Trans>Binary</Trans> </TabsContent>
</TabsTrigger> {/* Binary */}
</TabsList> <TabsContent value="binary">
</DialogHeader> <DialogDescription className="mb-4 leading-normal w-0 min-w-full">
{/* Docker */} <Trans>
<TabsContent value="docker"> The agent must be running on the system to connect. Copy the installation command for the agent below.
<DialogDescription className="mb-4 leading-normal w-0 min-w-full"> </Trans>
<Trans> </DialogDescription>
The agent must be running on the system to connect. Copy the </TabsContent>
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below. <form onSubmit={handleSubmit as any}>
</Trans> <div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
</DialogDescription> <Label htmlFor="name" className="xs:text-end">
</TabsContent> <Trans>Name</Trans>
{/* Binary */} </Label>
<TabsContent value="binary"> <Input id="name" name="name" defaultValue={system?.name} required />
<DialogDescription className="mb-4 leading-normal w-0 min-w-full"> <Label htmlFor="host" className="xs:text-end">
<Trans> <Trans>Host / IP</Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below. </Label>
</Trans> <Input id="host" name="host" defaultValue={system?.host} required />
</DialogDescription> <Label htmlFor="port" className="xs:text-end">
</TabsContent> <Trans>Port</Trans>
<form onSubmit={handleSubmit as any}> </Label>
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4"> <Input ref={port} name="port" id="port" defaultValue={system?.port || "45876"} required />
<Label htmlFor="name" className="xs:text-end"> <Label htmlFor="pkey" className="xs:text-end whitespace-pre">
<Trans>Name</Trans> <Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
</Label> </Label>
<Input id="name" name="name" className="" required /> <div className="relative">
<Label htmlFor="host" className="xs:text-end"> <Input readOnly id="pkey" value={publicKey} required></Input>
<Trans>Host / IP</Trans> <div
</Label> className={
<Input id="host" name="host" className="" required /> "h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
<Label htmlFor="port" className="xs:text-end"> }
<Trans>Port</Trans> ></div>
</Label> <TooltipProvider delayDuration={100}>
<Input ref={port} name="port" id="port" defaultValue="45876" className="" required /> <Tooltip>
<Label htmlFor="pkey" className="xs:text-end whitespace-pre"> <TooltipTrigger asChild>
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans> <Button
</Label> type="button"
<div className="relative"> variant={"link"}
<Input readOnly id="pkey" value={publicKey} className="" required></Input> className="absolute end-0 top-0"
<div onClick={() => copyToClipboard(publicKey)}
className={ >
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none" <Copy className="h-4 w-4 " />
} </Button>
></div> </TooltipTrigger>
<TooltipProvider delayDuration={100}> <TooltipContent>
<Tooltip> <p>
<TooltipTrigger asChild> <Trans>Click to copy</Trans>
<Button </p>
type="button" </TooltipContent>
variant={"link"} </Tooltip>
className="absolute end-0 top-0" </TooltipProvider>
onClick={() => copyToClipboard(publicKey)}
>
<Copy className="h-4 w-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div> </div>
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */} {/* Docker */}
<TabsContent value="docker"> <TabsContent value="docker" className="contents">
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col"> <div className="flex gap-0 rounded-lg">
<div className="flex gap-0 rounded-lg"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" onClick={() => copyDockerCompose(port.current.value)}
onClick={() => copyDockerCompose(port.current.value)} className="rounded-e-none dark:border-e-0 grow"
className="rounded-e-none dark:border-e-0 grow" >
> <Trans>Copy</Trans> docker compose
<Trans>Copy</Trans> docker compose
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={"px-2 rounded-s-none border-s-0"}>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => copyDockerRun(port.current.value)}>
<Trans>Copy</Trans> docker run
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button>
<Trans>Add system</Trans>
</Button> </Button>
</DialogFooter> <div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={"px-2 rounded-s-none border-s-0"}>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => copyDockerRun(port.current.value)}>
<Trans>Copy</Trans> docker run
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TabsContent> </TabsContent>
{/* Binary */} {/* Binary */}
<TabsContent value="binary"> <TabsContent value="binary" className="contents">
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col"> <Button type="button" variant="outline" onClick={() => copyInstallCommand(port.current.value)}>
<Button type="button" variant="outline" onClick={() => copyInstallCommand(port.current.value)}> <Trans>Copy Linux command</Trans>
<Trans>Copy Linux command</Trans> </Button>
</Button>
<Button>
<Trans>Add system</Trans>
</Button>
</DialogFooter>
</TabsContent> </TabsContent>
</form> {/* Save */}
</Tabs> <Button>{system ? <Trans>Save system</Trans> : <Trans>Add system</Trans>}</Button>
</DialogContent> </DialogFooter>
</Dialog> </form>
</Tabs>
</DialogContent>
) )
} })

View File

@@ -37,7 +37,6 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { SystemRecord } from "@/types" import { SystemRecord } from "@/types"
@@ -59,8 +58,9 @@ import {
ArrowUpIcon, ArrowUpIcon,
Settings2Icon, Settings2Icon,
EyeIcon, EyeIcon,
PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useState } from "react" import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $hubVersion, $systems, pb } from "@/lib/stores" import { $hubVersion, $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"
@@ -73,6 +73,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { ClassValue } from "clsx" import { ClassValue } from "clsx"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { SystemDialog } from "../add-system"
import { Dialog } from "../ui/dialog"
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid"
@@ -559,11 +561,15 @@ function IndicatorDot({ system, className }: { system: SystemRecord; className?:
) )
} }
function ActionsButton({ system }: { system: SystemRecord }) { const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
// const [opened, setOpened] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { id, status, host, name } = system const { id, status, host, name } = system
return ( return (
<AlertDialog> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink> <Button variant="ghost" size={"icon"} data-nolink>
@@ -574,6 +580,17 @@ function ActionsButton({ system }: { system: SystemRecord }) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")} className={cn(isReadOnlyUser() && "hidden")}
onClick={() => { onClick={() => {
@@ -599,38 +616,43 @@ function ActionsButton({ system }: { system: SystemRecord }) {
<Trans>Copy host</Trans> <Trans>Copy host</Trans>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} /> <DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<AlertDialogTrigger asChild> <DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}> <Trash2Icon className="me-2.5 size-4" />
<Trash2Icon className="me-2.5 size-4" /> <Trans>Delete</Trans>
<Trans>Delete</Trans> </DropdownMenuItem>
</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> {/* edit dialog */}
<AlertDialogHeader> <Dialog open={editOpen} onOpenChange={setEditOpen}>
<AlertDialogTitle> {editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
<Trans>Are you sure you want to delete {name}?</Trans> </Dialog>
</AlertDialogTitle> {/* deletion dialog */}
<AlertDialogDescription> <AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<Trans> <AlertDialogContent>
This action cannot be undone. This will permanently delete all current records for {name} from the <AlertDialogHeader>
database. <AlertDialogTitle>
</Trans> <Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogDescription> </AlertDialogTitle>
</AlertDialogHeader> <AlertDialogDescription>
<AlertDialogFooter> <Trans>
<AlertDialogCancel> This action cannot be undone. This will permanently delete all current records for {name} from the
<Trans>Cancel</Trans> database.
</AlertDialogCancel> </Trans>
<AlertDialogAction </AlertDialogDescription>
className={cn(buttonVariants({ variant: "destructive" }))} </AlertDialogHeader>
onClick={() => pb.collection("systems").delete(id)} <AlertDialogFooter>
> <AlertDialogCancel>
<Trans>Continue</Trans> <Trans>Cancel</Trans>
</AlertDialogAction> </AlertDialogCancel>
</AlertDialogFooter> <AlertDialogAction
</AlertDialogContent> className={cn(buttonVariants({ variant: "destructive" }))}
</AlertDialog> onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) )
} })

View File

@@ -53,7 +53,7 @@ export const updateSystemList = (() => {
try { try {
const records = await pb const records = await pb
.collection<SystemRecord>("systems") .collection<SystemRecord>("systems")
.getFullList({ sort: "+name", fields: "id,name,host,info,status" }) .getFullList({ sort: "+name", fields: "id,name,host,port,info,status" })
if (records.length) { if (records.length) {
$systems.set(records) $systems.set(records)
@@ -107,7 +107,7 @@ export const formatDay = (timestamp: string) => {
} }
export const updateFavicon = (newIcon: string) => { export const updateFavicon = (newIcon: string) => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath( `/static/${newIcon}` ) ;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`)
} }
export const isAdmin = () => pb.authStore.record?.role === "admin" export const isAdmin = () => pb.authStore.record?.role === "admin"