+
{/* @ts-ignore */}
@@ -112,5 +119,7 @@ function SettingsContent({ name }: { name: string }) {
return
case "config":
return
+ case "tokens":
+ return
}
}
diff --git a/beszel/site/src/components/routes/settings/sidebar-nav.tsx b/beszel/site/src/components/routes/settings/sidebar-nav.tsx
index 3ee7aad..a44bb60 100644
--- a/beszel/site/src/components/routes/settings/sidebar-nav.tsx
+++ b/beszel/site/src/components/routes/settings/sidebar-nav.tsx
@@ -31,9 +31,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
if (item.admin && !isAdmin()) return null
return (
-
+
{item.icon && }
- {item.title}
+ {item.title}
)
@@ -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.title}
+ {item.icon &&
}
+
{item.title}
)
})}
diff --git a/beszel/site/src/components/routes/settings/tokens-fingerprints.tsx b/beszel/site/src/components/routes/settings/tokens-fingerprints.tsx
new file mode 100644
index 0000000..2a9b915
--- /dev/null
+++ b/beszel/site/src/components/routes/settings/tokens-fingerprints.tsx
@@ -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
([])
+
+ // 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 (
+ <>
+
+
+
+
+
+ >
+ )
+})
+
+const SectionIntro = memo(() => {
+ return (
+
+
+ Tokens & Fingerprints
+
+
+ Tokens and fingerprints are used to authenticate WebSocket connections to the hub.
+
+
+
+ Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on
+ first connection.
+
+
+
+ )
+})
+
+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 (
+
+
+ Universal token
+
+
+
+ When enabled, this token allows agents to self-register without prior system creation. Expires after one hour
+ or on hub restart.
+
+
+
+ {!isLoading && (
+ <>
+
{
+ updateToken(checked ? 1 : 0)
+ }}
+ />
+
+ {token}
+
+
+ >
+ )}
+
+
+ )
+})
+
+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 (
+
+
+
+
+
+
+
+
+ )
+})
+
+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 (
+
+
+
+
+ {headerCols.map((col) => (
+
+
+
+ {col.label}
+
+
+ ))}
+ {!isReadOnly && (
+
+
+ Actions
+
+
+ )}
+
+
+
+ {fingerprints.map((fingerprint, i) => (
+
+ {fingerprint.expand.system.name}
+ {fingerprint.token}
+ {fingerprint.fingerprint}
+ {!isReadOnly && (
+
+
+
+ )}
+
+ ))}
+
+
+
+ )
+})
+
+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 (
+
+
+
+
+
+
+
+ Copy YAML
+
+
+
+ Copy env
+
+
+ updateFingerprint(fingerprint, true)}>
+
+ Rotate token
+
+ {fingerprint.fingerprint && (
+ updateFingerprint(fingerprint)}>
+
+ Delete fingerprint
+
+ )}
+
+
+ )
+})
+
+export default SettingsFingerprintsPage
diff --git a/beszel/site/src/components/ui/input-copy.tsx b/beszel/site/src/components/ui/input-copy.tsx
new file mode 100644
index 0000000..388291f
--- /dev/null
+++ b/beszel/site/src/components/ui/input-copy.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ Click to copy
+
+
+
+
+
+ )
+}
diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts
index bafa5c1..a256539 100644
--- a/beszel/site/src/lib/utils.ts
+++ b/beszel/site/src/lib/utils.ts
@@ -1,9 +1,9 @@
-import { t } from "@lingui/core/macro";
+import { t } from "@lingui/core/macro"
import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { $alerts, $copyContent, $systems, $userSettings, pb } from "./stores"
-import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from "@/types"
+import { AlertInfo, AlertRecord, ChartTimeData, ChartTimes, FingerprintRecord, SystemRecord } from "@/types"
import { RecordModel, RecordSubscription } from "pocketbase"
import { WritableAtom } from "nanostores"
import { timeDay, timeHour } from "d3-time"
@@ -17,13 +17,9 @@ export function cn(...inputs: ClassValue[]) {
}
/** Adds event listener to node and returns function that removes the listener */
-export function listen(
- node: Node,
- event: string,
- handler: (event: T) => void
-) {
- node.addEventListener(event, handler as EventListener)
- return () => node.removeEventListener(event, handler as EventListener)
+export function listen(node: Node, event: string, handler: (event: T) => void) {
+ node.addEventListener(event, handler as EventListener)
+ return () => node.removeEventListener(event, handler as EventListener)
}
export async function copyToClipboard(content: string) {
@@ -355,3 +351,12 @@ export const alertInfo: Record = {
* const hostname = getHostDisplayValue(system) // hostname will be "beszel.sock"
*/
export const getHostDisplayValue = (system: SystemRecord): string => system.host.slice(system.host.lastIndexOf("/") + 1)
+
+/** Generate a random token for the agent */
+export const generateToken = () => crypto?.randomUUID() ?? (performance.now() * Math.random()).toString(16)
+
+/** Get the hub URL from the global BESZEL object */
+export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
+
+/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
+export const tokenMap = new Map()
diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts
index d6095e4..c701907 100644
--- a/beszel/site/src/types.d.ts
+++ b/beszel/site/src/types.d.ts
@@ -6,6 +6,19 @@ declare global {
var BESZEL: {
BASE_PATH: string
HUB_VERSION: string
+ HUB_URL: string
+ }
+}
+
+export interface FingerprintRecord extends RecordModel {
+ id: string
+ system: string
+ fingerprint: string
+ token: string
+ expand: {
+ system: {
+ name: string
+ }
}
}
diff --git a/beszel/site/vite.config.ts b/beszel/site/vite.config.ts
index dd9dd02..094e3b5 100644
--- a/beszel/site/vite.config.ts
+++ b/beszel/site/vite.config.ts
@@ -15,7 +15,7 @@ export default defineConfig({
name: "replace version in index.html during dev",
apply: "serve",
transformIndexHtml(html) {
- return html.replace("{{V}}", version)
+ return html.replace("{{V}}", version).replace("{{HUB_URL}}", "")
},
},
],
diff --git a/beszel/version.go b/beszel/version.go
index d1aa177..afa095a 100644
--- a/beszel/version.go
+++ b/beszel/version.go
@@ -1,6 +1,10 @@
package beszel
+import "github.com/blang/semver"
+
const (
- Version = "0.11.1"
+ Version = "0.12.0-beta1"
AppName = "beszel"
)
+
+var MinVersionCbor = semver.MustParse("0.12.0-beta1")