mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 18:56:17 +01:00
- Add version exchange between hub and agent. - Introduce ConnectionManager for managing WebSocket and SSH connections. - Implement fingerprint generation and storage in agent. - Create expiry map package to store universal tokens. - Update config.yml configuration to include tokens. - Enhance system management with new methods for handling system states and alerts. - Update front-end components to support token / fingerprint management features. - Introduce utility functions for token generation and hub URL retrieval. Co-authored-by: nhas <jordanatararimu@gmail.com>
This commit is contained in:
@@ -11,20 +11,27 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { $publicKey, pb } from "@/lib/stores"
|
||||
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||
import { i18n } from "@lingui/core"
|
||||
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import { basePath, navigate } from "./router"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { $router, basePath, Link, navigate } from "./router"
|
||||
import { SystemRecord } from "@/types"
|
||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||
import { InputCopy } from "./ui/input-copy"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
copyDockerCompose,
|
||||
copyDockerRun,
|
||||
copyLinuxCommand,
|
||||
copyWindowsCommand,
|
||||
DropdownItem,
|
||||
InstallDropdown,
|
||||
} from "./install-dropdowns"
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||
|
||||
export function AddSystemButton({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -51,44 +58,11 @@ export function AddSystemButton({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function copyDockerCompose(port = "45876", publicKey: string) {
|
||||
copyToClipboard(`services:
|
||||
beszel-agent:
|
||||
image: "henrygd/beszel-agent"
|
||||
container_name: "beszel-agent"
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
||||
environment:
|
||||
LISTEN: ${port}
|
||||
KEY: "${publicKey}"`)
|
||||
}
|
||||
|
||||
function copyDockerRun(port = "45876", publicKey: string) {
|
||||
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 LISTEN=${port} henrygd/beszel-agent:latest`
|
||||
)
|
||||
}
|
||||
|
||||
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
|
||||
let cmd = `curl -sL https://get.beszel.dev${
|
||||
brew ? "/brew" : ""
|
||||
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
|
||||
// brew script does not support --china-mirrors
|
||||
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
|
||||
cmd += ` --china-mirrors`
|
||||
}
|
||||
copyToClipboard(cmd)
|
||||
}
|
||||
|
||||
function copyWindowsCommand(port = "45876", publicKey: string) {
|
||||
copyToClipboard(
|
||||
`& iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Token to be used for the next system.
|
||||
* Prevents token changing if user copies config, then closes dialog and opens again.
|
||||
*/
|
||||
let nextSystemToken: string | null = null
|
||||
|
||||
/**
|
||||
* SystemDialog component for adding or editing a system.
|
||||
@@ -96,12 +70,32 @@ function copyWindowsCommand(port = "45876", publicKey: string) {
|
||||
* @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 }) => {
|
||||
export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
|
||||
const publicKey = useStore($publicKey)
|
||||
const port = useRef<HTMLInputElement>(null)
|
||||
const [hostValue, setHostValue] = useState(system?.host ?? "")
|
||||
const isUnixSocket = hostValue.startsWith("/")
|
||||
const [tab, setTab] = useLocalStorage("as-tab", "docker")
|
||||
const [token, setToken] = useState(system?.token ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
// if no system, generate a new token
|
||||
if (!system) {
|
||||
nextSystemToken ||= generateToken()
|
||||
return setToken(nextSystemToken)
|
||||
}
|
||||
// if system exists,get the token from the fingerprint record
|
||||
if (tokenMap.has(system.id)) {
|
||||
return setToken(tokenMap.get(system.id)!)
|
||||
}
|
||||
const { token } = await pb.collection("fingerprints").getFirstListItem(`system = "${system.id}"`, {
|
||||
fields: "token",
|
||||
})
|
||||
tokenMap.set(system.id, token)
|
||||
setToken(token)
|
||||
})()
|
||||
}, [system?.id])
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
@@ -113,12 +107,18 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
if (system) {
|
||||
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
|
||||
} else {
|
||||
await pb.collection("systems").create(data)
|
||||
const createdSystem = await pb.collection("systems").create(data)
|
||||
await pb.collection("fingerprints").create({
|
||||
system: createdSystem.id,
|
||||
token,
|
||||
})
|
||||
// Reset the current token after successful system
|
||||
// creation so next system gets a new token
|
||||
nextSystemToken = null
|
||||
}
|
||||
navigate(basePath)
|
||||
// console.log(record)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,18 +143,37 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
</DialogHeader>
|
||||
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
|
||||
<TabsContent value="docker" tabIndex={-1}>
|
||||
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
||||
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
|
||||
<Trans>
|
||||
The agent must be running on the system to connect. Copy the
|
||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
||||
Copy the
|
||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
|
||||
below, or register agents automatically with a{" "}
|
||||
<Link
|
||||
onClick={() => setOpen(false)}
|
||||
href={getPagePath($router, "settings", { name: "tokens" })}
|
||||
className="link"
|
||||
>
|
||||
universal token
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</TabsContent>
|
||||
{/* Binary */}
|
||||
<TabsContent value="binary" tabIndex={-1}>
|
||||
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
|
||||
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
|
||||
<Trans>
|
||||
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
||||
Copy the installation command for the agent below, or register agents automatically with a{" "}
|
||||
<Link
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
href={getPagePath($router, "settings", { name: "tokens" })}
|
||||
className="link"
|
||||
>
|
||||
universal token
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</TabsContent>
|
||||
@@ -190,46 +209,27 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
|
||||
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input readOnly id="pkey" value={publicKey} required></Input>
|
||||
<div
|
||||
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"
|
||||
}
|
||||
></div>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"link"}
|
||||
className="absolute end-0 top-0"
|
||||
onClick={() => copyToClipboard(publicKey)}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
<Trans>Click to copy</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<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={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
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: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||
icons: [<DockerIcon className="size-4" />],
|
||||
onClick: async () =>
|
||||
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||
icons: [DockerIcon],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -239,22 +239,24 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
<CopyButton
|
||||
text={t`Copy Linux command`}
|
||||
icon={<TuxIcon className="size-4" />}
|
||||
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
|
||||
dropdownItems={[
|
||||
{
|
||||
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
|
||||
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
|
||||
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
|
||||
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: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||
icons: [<WindowsIcon className="size-4" />],
|
||||
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 className="size-4" />],
|
||||
icons: [ExternalLinkIcon],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -266,20 +268,13 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
)
|
||||
})
|
||||
|
||||
interface DropdownItem {
|
||||
text: string
|
||||
onClick?: () => void
|
||||
url?: string
|
||||
icons?: React.ReactNode[]
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
onClick: () => void
|
||||
dropdownItems: DropdownItem[]
|
||||
icon?: React.ReactNode
|
||||
icon?: React.ReactElement
|
||||
}
|
||||
|
||||
const CopyButton = memo((props: CopyButtonProps) => {
|
||||
@@ -300,22 +295,7 @@ const CopyButton = memo((props: CopyButtonProps) => {
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{props.dropdownItems.map((item, index) => {
|
||||
const className = "cursor-pointer flex items-center gap-1.5"
|
||||
return item.url ? (
|
||||
<DropdownMenuItem key={index} asChild>
|
||||
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{item.text} {item.icons?.map((icon) => icon)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
|
||||
{item.text} {item.icons?.map((icon) => icon)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
<InstallDropdown items={props.dropdownItems} />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BookIcon,
|
||||
DatabaseBackupIcon,
|
||||
FingerprintIcon,
|
||||
LayoutDashboard,
|
||||
LogsIcon,
|
||||
MailIcon,
|
||||
@@ -40,6 +41,16 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
|
||||
return useMemo(() => {
|
||||
const systems = $systems.get()
|
||||
const SettingsShortcut = (
|
||||
<CommandShortcut>
|
||||
<Trans>Settings</Trans>
|
||||
</CommandShortcut>
|
||||
)
|
||||
const AdminShortcut = (
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
)
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t`Search for systems or settings...`} />
|
||||
@@ -93,9 +104,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>Settings</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Settings</Trans>
|
||||
</CommandShortcut>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["alerts"]}
|
||||
@@ -108,9 +117,19 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>Notifications</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Settings</Trans>
|
||||
</CommandShortcut>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "tokens" }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<FingerprintIcon className="me-2 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Tokens & Fingerprints</Trans>
|
||||
</span>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["help", "oauth", "oidc"]}
|
||||
@@ -140,9 +159,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -154,9 +171,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>Logs</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -168,9 +183,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>Backups</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["email"]}
|
||||
@@ -183,9 +196,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
||||
<span>
|
||||
<Trans>SMTP settings</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
|
||||
97
beszel/site/src/components/install-dropdowns.tsx
Normal file
97
beszel/site/src/components/install-dropdowns.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { memo } from "react"
|
||||
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
|
||||
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
||||
import { i18n } from "@lingui/core"
|
||||
|
||||
const isBeta = BESZEL.HUB_VERSION.includes("beta")
|
||||
const imageTag = isBeta ? ":beta" : ""
|
||||
|
||||
/**
|
||||
* Get the URL of the script to install the agent.
|
||||
* @param path - The path to the script (e.g. "/brew").
|
||||
* @returns The URL for the script.
|
||||
*/
|
||||
const getScriptUrl = (path: string = "") => {
|
||||
const url = new URL("https://get.beszel.dev")
|
||||
url.pathname = path
|
||||
if (isBeta) {
|
||||
url.searchParams.set("beta", "1")
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function copyDockerCompose(port = "45876", publicKey: string, token: string) {
|
||||
copyToClipboard(`services:
|
||||
beszel-agent:
|
||||
image: henrygd/beszel-agent${imageTag}
|
||||
container_name: beszel-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./beszel_agent_data:/var/lib/beszel-agent
|
||||
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
||||
environment:
|
||||
LISTEN: ${port}
|
||||
KEY: '${publicKey}'
|
||||
TOKEN: ${token}
|
||||
HUB_URL: ${getHubURL()}`)
|
||||
}
|
||||
|
||||
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
|
||||
copyToClipboard(
|
||||
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent${imageTag}`
|
||||
)
|
||||
}
|
||||
|
||||
export function copyLinuxCommand(port = "45876", publicKey: string, token: string, brew = false) {
|
||||
let cmd = `curl -sL ${getScriptUrl(
|
||||
brew ? "/brew" : ""
|
||||
)} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}" -t "${token}" -url "${getHubURL()}"`
|
||||
// brew script does not support --china-mirrors
|
||||
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
|
||||
cmd += ` --china-mirrors`
|
||||
}
|
||||
copyToClipboard(cmd)
|
||||
}
|
||||
|
||||
export function copyWindowsCommand(port = "45876", publicKey: string, token: string) {
|
||||
copyToClipboard(
|
||||
`& iwr -useb ${getScriptUrl()} -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port} -Token "${token}" -Url "${getHubURL()}"`
|
||||
)
|
||||
}
|
||||
|
||||
export interface DropdownItem {
|
||||
text: string
|
||||
onClick?: () => void
|
||||
url?: string
|
||||
icons?: React.ComponentType<React.SVGProps<SVGSVGElement>>[]
|
||||
}
|
||||
|
||||
export const InstallDropdown = memo(({ items }: { items: DropdownItem[] }) => {
|
||||
return (
|
||||
<DropdownMenuContent align="end">
|
||||
{items.map((item, index) => {
|
||||
const className = "cursor-pointer flex items-center gap-1.5"
|
||||
return item.url ? (
|
||||
<DropdownMenuItem key={index} asChild>
|
||||
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{item.text}{" "}
|
||||
{item.icons?.map((Icon, iconIndex) => (
|
||||
<Icon key={iconIndex} className="size-4" />
|
||||
))}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
|
||||
{item.text}{" "}
|
||||
{item.icons?.map((Icon, iconIndex) => (
|
||||
<Icon key={iconIndex} className="size-4" />
|
||||
))}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
)
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $router } from "@/components/router.tsx"
|
||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
|
||||
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
|
||||
import { $userSettings, pb } from "@/lib/stores.ts"
|
||||
import { toast } from "@/components/ui/use-toast.ts"
|
||||
import { UserSettings } from "@/types.js"
|
||||
@@ -15,6 +15,7 @@ import General from "./general.tsx"
|
||||
import Notifications from "./notifications.tsx"
|
||||
import ConfigYaml from "./config-yaml.tsx"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import Fingerprints from "./tokens-fingerprints.tsx"
|
||||
|
||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
try {
|
||||
@@ -58,6 +59,12 @@ export default function SettingsLayout() {
|
||||
href: getPagePath($router, "settings", { name: "notifications" }),
|
||||
icon: BellIcon,
|
||||
},
|
||||
{
|
||||
title: t`Tokens & Fingerprints`,
|
||||
href: getPagePath($router, "settings", { name: "tokens" }),
|
||||
icon: FingerprintIcon,
|
||||
// admin: true,
|
||||
},
|
||||
{
|
||||
title: t`YAML Config`,
|
||||
href: getPagePath($router, "settings", { name: "config" }),
|
||||
@@ -77,7 +84,7 @@ export default function SettingsLayout() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
||||
<Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="mb-1">
|
||||
<Trans>Settings</Trans>
|
||||
@@ -89,10 +96,10 @@ export default function SettingsLayout() {
|
||||
<CardContent className="p-0">
|
||||
<Separator className="hidden md:block my-5" />
|
||||
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
|
||||
<aside className="md:w-48 w-full">
|
||||
<aside className="md:max-w-44 min-w-40">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* @ts-ignore */}
|
||||
<SettingsContent name={page?.params?.name ?? "general"} />
|
||||
</div>
|
||||
@@ -112,5 +119,7 @@ function SettingsContent({ name }: { name: string }) {
|
||||
return <Notifications userSettings={userSettings} />
|
||||
case "config":
|
||||
return <ConfigYaml />
|
||||
case "tokens":
|
||||
return <Fingerprints />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
if (item.admin && !isAdmin()) return null
|
||||
return (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
@@ -55,13 +55,12 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
href={item.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"flex items-center gap-3",
|
||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
|
||||
"justify-start"
|
||||
"flex items-center gap-3 justify-start truncate",
|
||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { $publicKey, pb } from "@/lib/stores"
|
||||
import { memo, useEffect, useMemo, useState } from "react"
|
||||
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
|
||||
import { FingerprintRecord } from "@/types"
|
||||
import {
|
||||
CopyIcon,
|
||||
FingerprintIcon,
|
||||
KeyIcon,
|
||||
MoreHorizontalIcon,
|
||||
RotateCwIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { cn, copyToClipboard, generateToken, getHubURL, isReadOnlyUser, tokenMap } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
copyDockerCompose,
|
||||
copyDockerRun,
|
||||
copyLinuxCommand,
|
||||
copyWindowsCommand,
|
||||
DropdownItem,
|
||||
InstallDropdown,
|
||||
} from "@/components/install-dropdowns"
|
||||
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
||||
|
||||
const pbFingerprintOptions = {
|
||||
expand: "system",
|
||||
fields: "id,fingerprint,token,system,expand.system.name",
|
||||
}
|
||||
|
||||
const SettingsFingerprintsPage = memo(() => {
|
||||
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
|
||||
|
||||
// Get fingerprint records on mount
|
||||
useEffect(() => {
|
||||
pb.collection("fingerprints")
|
||||
.getFullList(pbFingerprintOptions)
|
||||
// @ts-ignore
|
||||
.then(setFingerprints)
|
||||
}, [])
|
||||
|
||||
// Subscribe to fingerprint updates
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
;(async () => {
|
||||
// subscribe to fingerprint updates
|
||||
unsubscribe = await pb.collection("fingerprints").subscribe(
|
||||
"*",
|
||||
(res) => {
|
||||
setFingerprints((currentFingerprints) => {
|
||||
if (res.action === "create") {
|
||||
return [...currentFingerprints, res.record as FingerprintRecord]
|
||||
}
|
||||
if (res.action === "update") {
|
||||
return currentFingerprints.map((fingerprint) => {
|
||||
if (fingerprint.id === res.record.id) {
|
||||
return { ...fingerprint, ...res.record } as FingerprintRecord
|
||||
}
|
||||
return fingerprint
|
||||
})
|
||||
}
|
||||
if (res.action === "delete") {
|
||||
return currentFingerprints.filter((fingerprint) => fingerprint.id !== res.record.id)
|
||||
}
|
||||
return currentFingerprints
|
||||
})
|
||||
},
|
||||
pbFingerprintOptions
|
||||
)
|
||||
})()
|
||||
// unsubscribe on unmount
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// Update token map whenever fingerprints change
|
||||
useEffect(() => {
|
||||
for (const fingerprint of fingerprints) {
|
||||
tokenMap.set(fingerprint.system, fingerprint.token)
|
||||
}
|
||||
}, [fingerprints])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionIntro />
|
||||
<Separator className="my-4" />
|
||||
<SectionUniversalToken />
|
||||
<Separator className="my-4" />
|
||||
<SectionTable fingerprints={fingerprints} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const SectionIntro = memo(() => {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>Tokens & Fingerprints</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Tokens and fingerprints are used to authenticate WebSocket connections to the hub.</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mt-1.5">
|
||||
<Trans>
|
||||
Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on
|
||||
first connection.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SectionUniversalToken = memo(() => {
|
||||
const [token, setToken] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [checked, setChecked] = useState(false)
|
||||
|
||||
async function updateToken(enable: number = -1) {
|
||||
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
|
||||
const data = await pb.send(`/api/beszel/universal-token`, {
|
||||
query: {
|
||||
token,
|
||||
enable,
|
||||
},
|
||||
})
|
||||
setToken(data.token)
|
||||
setChecked(data.active)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateToken()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
<Trans>Universal token</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
When enabled, this token allows agents to self-register without prior system creation. Expires after one hour
|
||||
or on hub restart.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md">
|
||||
{!isLoading && (
|
||||
<>
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onCheckedChange={(checked) => {
|
||||
updateToken(checked ? 1 : 0)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-primary opacity-60 transition-opacity",
|
||||
checked ? "opacity-100" : "select-none"
|
||||
)}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
<ActionsButtonUniversalToken token={token} checked={checked} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; checked: boolean }) => {
|
||||
const publicKey = $publicKey.get()
|
||||
const port = "45876"
|
||||
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
text: "Copy Docker Compose",
|
||||
onClick: () => copyDockerCompose(port, publicKey, token),
|
||||
icons: [DockerIcon],
|
||||
},
|
||||
{
|
||||
text: "Copy Docker Run",
|
||||
onClick: () => copyDockerRun(port, publicKey, token),
|
||||
icons: [DockerIcon],
|
||||
},
|
||||
{
|
||||
text: "Copy Linux Command",
|
||||
onClick: () => copyLinuxCommand(port, publicKey, token),
|
||||
icons: [TuxIcon],
|
||||
},
|
||||
{
|
||||
text: "Copy Brew Command",
|
||||
onClick: () => copyLinuxCommand(port, publicKey, token, true),
|
||||
icons: [TuxIcon, AppleIcon],
|
||||
},
|
||||
{
|
||||
text: "Copy Windows Command",
|
||||
onClick: () => copyWindowsCommand(port, publicKey, token),
|
||||
icons: [WindowsIcon],
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!checked}
|
||||
className={cn("transition-opacity", !checked && "opacity-50")}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<InstallDropdown items={dropdownItems} />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRecord[] }) => {
|
||||
const isReadOnly = isReadOnlyUser()
|
||||
const headerCols = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "System",
|
||||
Icon: ServerIcon,
|
||||
w: "11em",
|
||||
},
|
||||
{
|
||||
label: "Token",
|
||||
Icon: KeyIcon,
|
||||
w: "20em",
|
||||
},
|
||||
{
|
||||
label: "Fingerprint",
|
||||
Icon: FingerprintIcon,
|
||||
w: "20em",
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden w-full mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headerCols.map((col) => (
|
||||
<TableHead key={col.label} style={{ minWidth: col.w }}>
|
||||
<span className="flex items-center gap-2">
|
||||
<col.Icon className="size-4" />
|
||||
{col.label}
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
{!isReadOnly && (
|
||||
<TableHead className="w-0">
|
||||
<span className="sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="whitespace-pre">
|
||||
{fingerprints.map((fingerprint, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
|
||||
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
|
||||
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</TableCell>
|
||||
{!isReadOnly && (
|
||||
<TableCell className="py-2.5 px-4 xl:px-2">
|
||||
<ActionsButtonTable fingerprint={fingerprint} />
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = false) {
|
||||
try {
|
||||
await pb.collection("fingerprints").update(fingerprint.id, {
|
||||
fingerprint: "",
|
||||
token: rotateToken ? generateToken() : fingerprint.token,
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ActionsButtonTable = memo(({ fingerprint }: { fingerprint: FingerprintRecord }) => {
|
||||
const envVar = `HUB_URL=${getHubURL()}\nTOKEN=${fingerprint.token}`
|
||||
const copyEnv = () => copyToClipboard(envVar)
|
||||
const copyYaml = () => copyToClipboard(envVar.replaceAll("=", ": "))
|
||||
|
||||
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">
|
||||
<DropdownMenuItem onClick={copyYaml}>
|
||||
<CopyIcon className="me-2.5 size-4" />
|
||||
<Trans>Copy YAML</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyEnv}>
|
||||
<CopyIcon className="me-2.5 size-4" />
|
||||
<Trans context="Environment variables">Copy env</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint, true)}>
|
||||
<RotateCwIcon className="me-2.5 size-4" />
|
||||
<Trans>Rotate token</Trans>
|
||||
</DropdownMenuItem>
|
||||
{fingerprint.fingerprint && (
|
||||
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint)}>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete fingerprint</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
export default SettingsFingerprintsPage
|
||||
38
beszel/site/src/components/ui/input-copy.tsx
Normal file
38
beszel/site/src/components/ui/input-copy.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { copyToClipboard } from "@/lib/utils"
|
||||
import { Input } from "./input"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
|
||||
import { CopyIcon } from "lucide-react"
|
||||
import { Button } from "./button"
|
||||
|
||||
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input readOnly id={id} name={name} value={value} required></Input>
|
||||
<div
|
||||
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"
|
||||
}
|
||||
></div>
|
||||
<TooltipProvider delayDuration={100} disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"link"}
|
||||
className="absolute end-0 top-0"
|
||||
onClick={() => copyToClipboard(value)}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
<Trans>Click to copy</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user