mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-04 05:12:56 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { alertInfo } from "@/lib/alerts"
|
||||
import { $alerts, $allSystemsById } from "@/lib/stores"
|
||||
import type { AlertRecord } from "@/types"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { useMemo } from "react"
|
||||
import { $router, Link } from "./router"
|
||||
import { Alert, AlertTitle, AlertDescription } from "./ui/alert"
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
|
||||
|
||||
export const ActiveAlerts = () => {
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($allSystemsById)
|
||||
|
||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||
const activeAlerts: AlertRecord[] = []
|
||||
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||
const alertsKey: string[] = []
|
||||
|
||||
for (const systemId of Object.keys(alerts)) {
|
||||
for (const alert of alerts[systemId].values()) {
|
||||
if (alert.triggered && alert.name in alertInfo) {
|
||||
activeAlerts.push(alert)
|
||||
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { activeAlerts, alertsKey }
|
||||
}, [alerts])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
||||
return useMemo(() => {
|
||||
if (activeAlerts.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<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-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
||||
>
|
||||
<info.icon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{systems[alert.system]?.name} {info.name()}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{alert.name === "Status" ? (
|
||||
<Trans>Connection is down</Trans>
|
||||
) : info.invert ? (
|
||||
<Trans>
|
||||
Below {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Exceeds {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-label="View system"
|
||||
></Link>
|
||||
</Alert>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}, [alertsKey.join("")])
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { ChevronDownIcon, ExternalLinkIcon } from "lucide-react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import { $publicKey } from "@/lib/stores"
|
||||
import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import {
|
||||
copyDockerCompose,
|
||||
copyDockerRun,
|
||||
copyLinuxCommand,
|
||||
copyWindowsCommand,
|
||||
type DropdownItem,
|
||||
InstallDropdown,
|
||||
} from "./install-dropdowns"
|
||||
import { $router, basePath, Link, navigate } from "./router"
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||
import { InputCopy } from "./ui/input-copy"
|
||||
|
||||
// To avoid a refactor of the dialog, we will just keep this function as a "skeleton" for the actual dialog
|
||||
export function AddSystemDialog({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||
const opened = useRef(false)
|
||||
if (open) {
|
||||
opened.current = true
|
||||
}
|
||||
|
||||
if (isReadOnlyUser()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{opened.current && <SystemDialog setOpen={setOpen} />}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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 = ({ 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] = useBrowserStorage("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, nextSystemToken])
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.target as HTMLFormElement)
|
||||
const data = Object.fromEntries(formData) as Record<string, any>
|
||||
data.users = pb.authStore.record!.id
|
||||
try {
|
||||
setOpen(false)
|
||||
if (system) {
|
||||
await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending })
|
||||
} else {
|
||||
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)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const systemTranslation = t`System`
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
||||
onCloseAutoFocus={() => {
|
||||
setHostValue(system?.host ?? "")
|
||||
}}
|
||||
>
|
||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
||||
{system ? (
|
||||
<Trans>Edit {{ foo: systemTranslation }}</Trans>
|
||||
) : (
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||
<TabsTrigger value="binary">
|
||||
<Trans>Binary</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</DialogHeader>
|
||||
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
|
||||
<TabsContent value="docker" tabIndex={-1}>
|
||||
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
|
||||
<Trans>
|
||||
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-3 leading-relaxed w-0 min-w-full">
|
||||
<Trans>
|
||||
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>
|
||||
<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>
|
||||
{/* 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({ message: "FreeBSD command", context: "Button to copy install command" }),
|
||||
onClick: async () =>
|
||||
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
|
||||
icons: [FreeBsdIcon],
|
||||
},
|
||||
{
|
||||
text: t`Manual setup instructions`,
|
||||
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||
icons: [ExternalLinkIcon],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TabsContent>
|
||||
{/* Save */}
|
||||
<Button>
|
||||
{system ? (
|
||||
<Trans>Save {{ foo: systemTranslation }}</Trans>
|
||||
) : (
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
onClick: () => void
|
||||
dropdownItems: DropdownItem[]
|
||||
icon?: React.ReactElement<any>
|
||||
}
|
||||
|
||||
const CopyButton = memo((props: CopyButtonProps) => {
|
||||
return (
|
||||
<div className="flex gap-0 rounded-lg">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={props.onClick}
|
||||
className="rounded-e-none dark:border-e-0 grow flex items-center gap-2"
|
||||
>
|
||||
{props.text} {props.icon}
|
||||
</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>
|
||||
<InstallDropdown items={props.dropdownItems} />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { alertInfo } from "@/lib/alerts"
|
||||
import { cn, formatDuration, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||
import type { AlertsHistoryRecord } from "@/types"
|
||||
|
||||
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
|
||||
{
|
||||
accessorKey: "system",
|
||||
enableSorting: true,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans>System</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="ps-2 max-w-60 truncate">{row.original.expand?.system?.name || row.original.system}</div>
|
||||
),
|
||||
filterFn: (row, _, filterValue) => {
|
||||
const display = row.original.expand?.system?.name || row.original.system || ""
|
||||
return display.toLowerCase().includes(filterValue.toLowerCase())
|
||||
},
|
||||
},
|
||||
{
|
||||
// accessorKey: "name",
|
||||
id: "name",
|
||||
accessorFn: (record) => {
|
||||
const name = record.name
|
||||
const info = alertInfo[name]
|
||||
return info?.name().replace("cpu", "CPU") || name
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans>Name</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ getValue, row }) => {
|
||||
const name = getValue() as string
|
||||
const info = alertInfo[row.original.name]
|
||||
const Icon = info?.icon
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2 ps-1 min-w-40">
|
||||
{Icon && <Icon className="size-3.5" />}
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
enableSorting: false,
|
||||
header: () => (
|
||||
<Button variant="ghost">
|
||||
<Trans>Value</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell({ row, getValue }) {
|
||||
const name = row.original.name
|
||||
if (name === "Status") {
|
||||
return <span className="ps-2">{t`Down`}</span>
|
||||
}
|
||||
const value = getValue() as number
|
||||
const unit = alertInfo[name]?.unit
|
||||
return (
|
||||
<span className="tabular-nums ps-2.5">
|
||||
{toFixedFloat(value, value < 10 ? 2 : 1)}
|
||||
{unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans comment="Context: alert state (active or resolved)">State</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resolved = row.original.resolved
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"capitalize pointer-events-none",
|
||||
resolved
|
||||
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
|
||||
: "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||
)}
|
||||
>
|
||||
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
|
||||
{resolved ? <Trans>Resolved</Trans> : <Trans>Active</Trans>}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "created",
|
||||
accessorFn: (record) => formatShortDate(record.created),
|
||||
enableSorting: true,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans comment="Context: date created">Created</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ getValue, row }) => (
|
||||
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
|
||||
{getValue() as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "resolved",
|
||||
enableSorting: true,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans>Resolved</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row, getValue }) => {
|
||||
const resolved = getValue() as string | null
|
||||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
|
||||
{formatShortDate(resolved)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
invertSorting: true,
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aCreated = new Date(rowA.original.created)
|
||||
const bCreated = new Date(rowB.original.created)
|
||||
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
|
||||
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
|
||||
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
|
||||
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
|
||||
if (!aDuration && bDuration) return -1
|
||||
if (aDuration && !bDuration) return 1
|
||||
return (aDuration || 0) - (bDuration || 0)
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
<Trans>Duration</Trans>
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const duration = formatDuration(row.original.created, row.original.resolved)
|
||||
if (!duration) {
|
||||
return null
|
||||
}
|
||||
return <span className="ps-2">{duration}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { BellIcon } from "lucide-react"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { $alerts } from "@/lib/stores"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import { AlertDialogContent } from "./alerts-sheet"
|
||||
|
||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||
const [opened, setOpened] = useState(false)
|
||||
const alerts = useStore($alerts)
|
||||
|
||||
const hasSystemAlert = alerts[system.id]?.size > 0
|
||||
return useMemo(
|
||||
() => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||
<BellIcon
|
||||
className={cn("size-[1.2em] pointer-events-none", {
|
||||
"fill-primary": hasSystemAlert,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="max-h-full overflow-auto w-160 !max-w-full p-4 sm:p-6">
|
||||
{opened && <AlertDialogContent system={system} />}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
[opened, hasSystemAlert]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,408 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
||||
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||
import { $router, Link } from "@/components/router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { alertInfo } from "@/lib/alerts"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $alerts, $systems } from "@/lib/stores"
|
||||
import { cn, debounce } from "@/lib/utils"
|
||||
import type { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||
|
||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||
|
||||
const endpoint = "/api/beszel/user-alerts"
|
||||
|
||||
const alertDebounce = 400
|
||||
|
||||
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
|
||||
|
||||
const failedUpdateToast = (error: unknown) => {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: t`Failed to update alert`,
|
||||
description: t`Please check logs for more details.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
/** Create or update alerts for a given name and systems */
|
||||
const upsertAlerts = debounce(
|
||||
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
|
||||
try {
|
||||
await pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "POST",
|
||||
// overwrite is always true because we've done filtering client side
|
||||
body: { name, value, min, systems, overwrite: true },
|
||||
})
|
||||
} catch (error) {
|
||||
failedUpdateToast(error)
|
||||
}
|
||||
},
|
||||
alertDebounce
|
||||
)
|
||||
|
||||
/** Delete alerts for a given name and systems */
|
||||
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
|
||||
try {
|
||||
await pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "DELETE",
|
||||
body: { name, systems },
|
||||
})
|
||||
} catch (error) {
|
||||
failedUpdateToast(error)
|
||||
}
|
||||
}, alertDebounce)
|
||||
|
||||
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($systems)
|
||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||
const [currentTab, setCurrentTab] = useState("system")
|
||||
// copyKey is used to force remount AlertContent components with
|
||||
// new alert data after copying alerts from another system
|
||||
const [copyKey, setCopyKey] = useState(0)
|
||||
|
||||
const systemAlerts = alerts[system.id] ?? new Map()
|
||||
|
||||
// Systems that have at least one alert configured (excluding the current system)
|
||||
const systemsWithAlerts = useMemo(
|
||||
() => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size),
|
||||
[systems, alerts, system.id]
|
||||
)
|
||||
|
||||
async function copyAlertsFromSystem(sourceSystemId: string) {
|
||||
const sourceAlerts = $alerts.get()[sourceSystemId]
|
||||
if (!sourceAlerts?.size) return
|
||||
try {
|
||||
const currentTargetAlerts = $alerts.get()[system.id] ?? new Map()
|
||||
// Alert names present on target but absent from source should be deleted
|
||||
const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name))
|
||||
await Promise.all([
|
||||
...Array.from(sourceAlerts.values()).map(({ name, value, min }) =>
|
||||
pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "POST",
|
||||
body: { name, value, min, systems: [system.id], overwrite: true },
|
||||
requestKey: name,
|
||||
})
|
||||
),
|
||||
...namesToDelete.map((name) =>
|
||||
pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "DELETE",
|
||||
body: { name, systems: [system.id] },
|
||||
requestKey: name,
|
||||
})
|
||||
),
|
||||
])
|
||||
// Optimistically update the store so components re-mount with correct data
|
||||
// before the realtime subscription event arrives.
|
||||
const newSystemAlerts = new Map<string, AlertRecord>()
|
||||
for (const alert of sourceAlerts.values()) {
|
||||
newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false })
|
||||
}
|
||||
$alerts.setKey(system.id, newSystemAlerts)
|
||||
setCopyKey((k) => k + 1)
|
||||
} catch (error) {
|
||||
failedUpdateToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
|
||||
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||
// after that it exists.
|
||||
const alertsWhenGlobalSelected = useMemo(() => {
|
||||
return currentTab === "global" ? structuredClone(alerts) : alerts
|
||||
}, [currentTab])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
<Trans>Alerts</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
See{" "}
|
||||
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
||||
notification settings
|
||||
</Link>{" "}
|
||||
to configure how you receive alerts.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||
<div className="flex items-center justify-between mb-1 -mt-0.5">
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">
|
||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||
<span className="truncate max-w-60">{system.name}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="global">
|
||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||
<Trans>All Systems</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{systemsWithAlerts.length > 0 && currentTab === "system" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs gap-1.5">
|
||||
<Trans context="Copy alerts from another system">Copy from</Trans>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-100 overflow-auto">
|
||||
{systemsWithAlerts.map((s) => (
|
||||
<DropdownMenuItem key={s.id} className="min-w-44" onSelect={() => copyAlertsFromSystem(s.id)}>
|
||||
{s.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="system">
|
||||
<div key={copyKey} className="grid gap-3">
|
||||
{alertKeys.map((name) => (
|
||||
<AlertContent
|
||||
key={name}
|
||||
alertKey={name}
|
||||
data={alertInfo[name as keyof typeof alertInfo]}
|
||||
alert={systemAlerts.get(name)}
|
||||
system={system}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="global">
|
||||
<label
|
||||
htmlFor="ovw"
|
||||
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id="ovw"
|
||||
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||
checked={overwriteExisting}
|
||||
onCheckedChange={setOverwriteExisting}
|
||||
/>
|
||||
<Trans>Overwrite existing alerts</Trans>
|
||||
</label>
|
||||
<div className="grid gap-3">
|
||||
{alertKeys.map((name) => (
|
||||
<AlertContent
|
||||
key={name}
|
||||
alertKey={name}
|
||||
system={system}
|
||||
alert={systemAlerts.get(name)}
|
||||
data={alertInfo[name as keyof typeof alertInfo]}
|
||||
global={true}
|
||||
overwriteExisting={!!overwriteExisting}
|
||||
initialAlertsState={alertsWhenGlobalSelected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export function AlertContent({
|
||||
alertKey,
|
||||
data: alertData,
|
||||
system,
|
||||
alert,
|
||||
global = false,
|
||||
overwriteExisting = false,
|
||||
initialAlertsState = {},
|
||||
}: {
|
||||
alertKey: string
|
||||
data: AlertInfo
|
||||
system: SystemRecord
|
||||
alert?: AlertRecord
|
||||
global?: boolean
|
||||
overwriteExisting?: boolean
|
||||
initialAlertsState?: Record<string, Map<string, AlertRecord>>
|
||||
}) {
|
||||
const { name } = alertData
|
||||
|
||||
const singleDescription = alertData.singleDesc?.()
|
||||
|
||||
const [checked, setChecked] = useState(global ? false : !!alert)
|
||||
const [min, setMin] = useState(alert?.min || 10)
|
||||
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : (alertData.start ?? 80)))
|
||||
|
||||
const Icon = alertData.icon
|
||||
|
||||
/** Get system ids to update */
|
||||
function getSystemIds(): string[] {
|
||||
// if not global, update only the current system
|
||||
if (!global) {
|
||||
return [system.id]
|
||||
}
|
||||
// if global, update all systems when overwriteExisting is true
|
||||
// update only systems without an existing alert when overwriteExisting is false
|
||||
const allSystems = $systems.get()
|
||||
const systemIds: string[] = []
|
||||
for (const system of allSystems) {
|
||||
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
|
||||
systemIds.push(system.id)
|
||||
}
|
||||
}
|
||||
return systemIds
|
||||
}
|
||||
|
||||
function sendUpsert(min: number, value: number) {
|
||||
const systems = getSystemIds()
|
||||
systems.length &&
|
||||
upsertAlerts({
|
||||
name: alertKey,
|
||||
value,
|
||||
min,
|
||||
systems,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||
<label
|
||||
htmlFor={`s${name}`}
|
||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||
"pb-0": checked,
|
||||
})}
|
||||
>
|
||||
<div className="grid gap-1 select-none">
|
||||
<p className="font-semibold flex gap-3 items-center">
|
||||
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
|
||||
</p>
|
||||
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
id={`s${name}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(newChecked) => {
|
||||
setChecked(newChecked)
|
||||
if (newChecked) {
|
||||
// if alert checked, create or update alert
|
||||
sendUpsert(min, value)
|
||||
} else {
|
||||
// if unchecked, delete alert (unless global and overwriteExisting is false)
|
||||
deleteAlerts({ name: alertKey, systems: getSystemIds() })
|
||||
// when force deleting all alerts of a type, also remove them from initialAlertsState
|
||||
if (overwriteExisting) {
|
||||
for (const curAlerts of Object.values(initialAlertsState)) {
|
||||
curAlerts.delete(alertKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{checked && (
|
||||
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||
<Suspense fallback={<div className="h-10" />}>
|
||||
{!singleDescription && (
|
||||
<div>
|
||||
<p id={`v${name}`} className="text-sm block h-6">
|
||||
{alertData.invert ? (
|
||||
<Trans>
|
||||
Average drops below{" "}
|
||||
<strong className="text-foreground">
|
||||
{value}
|
||||
{alertData.unit}
|
||||
</strong>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Average exceeds{" "}
|
||||
<strong className="text-foreground">
|
||||
{value}
|
||||
{alertData.unit}
|
||||
</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Slider
|
||||
aria-labelledby={`v${name}`}
|
||||
value={[value]}
|
||||
onValueCommit={(val) => sendUpsert(min, val[0])}
|
||||
onValueChange={(val) => setValue(val[0])}
|
||||
step={alertData.step ?? 1}
|
||||
min={alertData.min ?? 1}
|
||||
max={alertData.max ?? 99}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
let val = parseFloat(e.target.value)
|
||||
if (!Number.isNaN(val)) {
|
||||
if (alertData.max != null) val = Math.min(val, alertData.max)
|
||||
if (alertData.min != null) val = Math.max(val, alertData.min)
|
||||
setValue(val)
|
||||
sendUpsert(min, val)
|
||||
}
|
||||
}}
|
||||
step={alertData.step ?? 1}
|
||||
min={alertData.min ?? 1}
|
||||
max={alertData.max ?? 99}
|
||||
className="w-16 h-8 text-center px-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||
<p id={`t${name}`} className="text-sm block h-6 first-letter:uppercase">
|
||||
{singleDescription && (
|
||||
<>
|
||||
{singleDescription}
|
||||
{` `}
|
||||
</>
|
||||
)}
|
||||
<Trans>
|
||||
For <strong className="text-foreground">{min}</strong>{" "}
|
||||
<Plural value={min} one="minute" other="minutes" />
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Slider
|
||||
aria-labelledby={`t${name}`}
|
||||
value={[min]}
|
||||
onValueCommit={(val) => sendUpsert(val[0], value)}
|
||||
onValueChange={(val) => setMin(val[0])}
|
||||
min={1}
|
||||
max={60}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => {
|
||||
let val = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(val)) {
|
||||
val = Math.max(1, Math.min(val, 60))
|
||||
setMin(val)
|
||||
sendUpsert(val, value)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={60}
|
||||
className="w-16 h-8 text-center px-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar as CalendarIcon,
|
||||
AlertCircle,
|
||||
Globe,
|
||||
Shield,
|
||||
} from "lucide-react"
|
||||
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
|
||||
import { formatDate } from "@/lib/domains"
|
||||
|
||||
export function CalendarView() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
|
||||
const { data: events, isLoading } = useQuery({
|
||||
queryKey: ["calendar-events"],
|
||||
queryFn: getCalendarEvents,
|
||||
})
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
|
||||
const daysInMonth = useMemo(() => {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}, [year, month])
|
||||
|
||||
const firstDayOfMonth = useMemo(() => {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}, [year, month])
|
||||
|
||||
const days = useMemo(() => {
|
||||
const d: { day: number; events: CalendarEvent[] }[] = []
|
||||
|
||||
// Empty cells for days before start of month
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
d.push({ day: 0, events: [] })
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
const dayEvents = events?.filter((e) => e.date === dateStr) || []
|
||||
d.push({ day, events: dayEvents })
|
||||
}
|
||||
|
||||
return d
|
||||
}, [year, month, daysInMonth, firstDayOfMonth, events])
|
||||
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1))
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(year, month + 1, 1))
|
||||
}
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "domain_expiry":
|
||||
return <Globe className="h-3 w-3" />
|
||||
case "ssl_expiry":
|
||||
return <Shield className="h-3 w-3" />
|
||||
case "incident":
|
||||
return <AlertCircle className="h-3 w-3" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
Calendar View
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-96 flex items-center justify-center">Loading...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
Calendar View
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={prevMonth}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-medium min-w-[140px] text-center">
|
||||
{monthNames[month]} {year}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={nextMonth}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-sm font-medium text-muted-foreground mb-2">
|
||||
<div>Sun</div>
|
||||
<div>Mon</div>
|
||||
<div>Tue</div>
|
||||
<div>Wed</div>
|
||||
<div>Thu</div>
|
||||
<div>Fri</div>
|
||||
<div>Sat</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`min-h-[100px] border rounded-lg p-2 ${
|
||||
day.day === 0 ? "bg-muted/30" : "bg-card"
|
||||
}`}
|
||||
>
|
||||
{day.day > 0 && (
|
||||
<>
|
||||
<div className="font-medium text-sm mb-1">{day.day}</div>
|
||||
<div className="space-y-1">
|
||||
{day.events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="text-xs p-1 rounded flex items-center gap-1"
|
||||
style={{ backgroundColor: event.color + "20", color: event.color }}
|
||||
title={event.title}
|
||||
>
|
||||
{getEventIcon(event.type)}
|
||||
<span className="truncate">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-red-500" />
|
||||
<span>Domain Expiring (< 7 days)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-orange-500" />
|
||||
<span>Domain Expiring (< 30 days)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-purple-500" />
|
||||
<span>SSL Expiry</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-gray-500" />
|
||||
<span>Incident</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from "@/components/ui/chart"
|
||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
|
||||
export type DataPoint<T = SystemStatsRecord> = {
|
||||
label: string
|
||||
dataKey: (data: T) => number | null | undefined
|
||||
color: number | string
|
||||
opacity: number
|
||||
stackId?: string | number
|
||||
order?: number
|
||||
strokeOpacity?: number
|
||||
activeDot?: boolean
|
||||
}
|
||||
|
||||
export default function AreaChartDefault({
|
||||
chartData,
|
||||
customData,
|
||||
max,
|
||||
maxToggled,
|
||||
tickFormatter,
|
||||
contentFormatter,
|
||||
dataPoints,
|
||||
domain,
|
||||
legend,
|
||||
itemSorter,
|
||||
showTotal = false,
|
||||
reverseStackOrder = false,
|
||||
hideYAxis = false,
|
||||
filter,
|
||||
truncate = false,
|
||||
chartProps,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||
customData?: any[]
|
||||
max?: number
|
||||
maxToggled?: boolean
|
||||
tickFormatter: (value: number, index: number) => string
|
||||
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||
contentFormatter: (item: any, key: string) => ReactNode
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
|
||||
dataPoints?: DataPoint<any>[]
|
||||
domain?: AxisDomain
|
||||
legend?: boolean
|
||||
showTotal?: boolean
|
||||
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||
itemSorter?: (a: any, b: any) => number
|
||||
reverseStackOrder?: boolean
|
||||
hideYAxis?: boolean
|
||||
filter?: string
|
||||
truncate?: boolean
|
||||
chartProps?: Omit<React.ComponentProps<typeof AreaChart>, "data" | "margin">
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||
useEffect(() => {
|
||||
const shouldPrimeData = sourceData.length && !displayData.length
|
||||
const sourceChanged = sourceData !== displayData
|
||||
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
|
||||
if (shouldUpdate) {
|
||||
setDisplayData(sourceData)
|
||||
}
|
||||
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||
setDisplayMaxToggled(maxToggled)
|
||||
}
|
||||
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||
|
||||
// Use a stable key derived from data point identities and visual properties
|
||||
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
||||
|
||||
const Areas = useMemo(() => {
|
||||
return dataPoints?.map((dataPoint, i) => {
|
||||
let { color } = dataPoint
|
||||
if (typeof color === "number") {
|
||||
color = `var(--chart-${color})`
|
||||
}
|
||||
return (
|
||||
<Area
|
||||
key={dataPoint.label}
|
||||
dataKey={dataPoint.dataKey}
|
||||
name={dataPoint.label}
|
||||
type="monotoneX"
|
||||
fill={color}
|
||||
fillOpacity={dataPoint.opacity}
|
||||
stroke={color}
|
||||
strokeOpacity={dataPoint.strokeOpacity}
|
||||
isAnimationActive={false}
|
||||
stackId={dataPoint.stackId}
|
||||
order={dataPoint.order || i}
|
||||
activeDot={dataPoint.activeDot ?? true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}, [areasKey, displayMaxToggled])
|
||||
|
||||
return useMemo(() => {
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
// if (logRender) {
|
||||
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||
// }
|
||||
return (
|
||||
<ChartContainer
|
||||
ref={ref}
|
||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||
"opacity-100": yAxisWidth || hideYAxis,
|
||||
"ps-4": hideYAxis,
|
||||
})}
|
||||
>
|
||||
<AreaChart
|
||||
reverseStackOrder={reverseStackOrder}
|
||||
accessibilityLayer
|
||||
data={displayData}
|
||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||
{...chartProps}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{!hideYAxis && (
|
||||
<YAxis
|
||||
direction="ltr"
|
||||
orientation={chartData.orientation}
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
domain={domain ?? [0, max ?? "auto"]}
|
||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
)}
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
// @ts-expect-error
|
||||
itemSorter={itemSorter}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={contentFormatter}
|
||||
showTotal={showTotal}
|
||||
filter={filter}
|
||||
truncate={truncate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Areas}
|
||||
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}, [displayData, yAxisWidth, filter, Areas])
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { HistoryIcon } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { $chartTime } from "@/lib/stores"
|
||||
import { chartTimeData, cn, compareSemVer, parseSemVer } from "@/lib/utils"
|
||||
import type { ChartTimes, SemVer } from "@/types"
|
||||
import { memo } from "react"
|
||||
|
||||
export default memo(function ChartTimeSelect({
|
||||
className,
|
||||
agentVersion,
|
||||
}: {
|
||||
className?: string
|
||||
agentVersion: SemVer
|
||||
}) {
|
||||
const chartTime = useStore($chartTime)
|
||||
|
||||
// remove chart times that are not supported by the system agent version
|
||||
const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => {
|
||||
if (!minVersion) {
|
||||
return true
|
||||
}
|
||||
return compareSemVer(agentVersion, parseSemVer(minVersion)) >= 0
|
||||
})
|
||||
|
||||
return (
|
||||
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
|
||||
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
|
||||
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableChartTimes.map(([value, { label }]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import type { ChartConfig } from "@/components/ui/chart"
|
||||
import type { ChartData, SystemStats, SystemStatsRecord } from "@/types"
|
||||
import type { DataPoint } from "./area-chart"
|
||||
import { $containerFilter } from "@/lib/stores"
|
||||
|
||||
/** Chart configurations for CPU, memory, and network usage charts */
|
||||
export interface ContainerChartConfigs {
|
||||
cpu: ChartConfig
|
||||
memory: ChartConfig
|
||||
network: ChartConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates chart configurations for container metrics visualization
|
||||
* @param containerData - Array of container statistics data points
|
||||
* @returns Chart configurations for CPU, memory, and network metrics
|
||||
*/
|
||||
export function useContainerChartConfigs(containerData: ChartData["containerData"]): ContainerChartConfigs {
|
||||
return useMemo(() => {
|
||||
const configs = {
|
||||
cpu: {} as ChartConfig,
|
||||
memory: {} as ChartConfig,
|
||||
network: {} as ChartConfig,
|
||||
}
|
||||
|
||||
// Aggregate usage metrics for each container
|
||||
const totalUsage = {
|
||||
cpu: new Map<string, number>(),
|
||||
memory: new Map<string, number>(),
|
||||
network: new Map<string, number>(),
|
||||
}
|
||||
|
||||
// Process each data point to calculate totals
|
||||
for (let i = 0; i < containerData.length; i++) {
|
||||
const stats = containerData[i]
|
||||
const containerNames = Object.keys(stats)
|
||||
|
||||
for (let j = 0; j < containerNames.length; j++) {
|
||||
const containerName = containerNames[j]
|
||||
// Skip metadata field
|
||||
if (containerName === "created") {
|
||||
continue
|
||||
}
|
||||
|
||||
const containerStats = stats[containerName]
|
||||
if (!containerStats) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate metrics for CPU, memory, and network
|
||||
const currentCpu = totalUsage.cpu.get(containerName) ?? 0
|
||||
const currentMemory = totalUsage.memory.get(containerName) ?? 0
|
||||
const currentNetwork = totalUsage.network.get(containerName) ?? 0
|
||||
const sentBytes = containerStats.b?.[0] ?? (containerStats.ns ?? 0) * 1024 * 1024
|
||||
const recvBytes = containerStats.b?.[1] ?? (containerStats.nr ?? 0) * 1024 * 1024
|
||||
|
||||
totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
|
||||
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0))
|
||||
totalUsage.network.set(containerName, currentNetwork + sentBytes + recvBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate chart configurations for each metric type
|
||||
Object.entries(totalUsage).forEach(([chartType, usageMap]) => {
|
||||
const sortedContainers = Array.from(usageMap.entries()).sort(([, a], [, b]) => b - a)
|
||||
const chartConfig = {} as Record<string, { label: string; color: string }>
|
||||
const count = sortedContainers.length
|
||||
|
||||
// Generate colors for each container
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [containerName] = sortedContainers[i]
|
||||
const hue = ((i * 360) / count) % 360
|
||||
chartConfig[containerName] = {
|
||||
label: containerName,
|
||||
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
}
|
||||
}
|
||||
|
||||
configs[chartType as keyof typeof configs] = chartConfig
|
||||
})
|
||||
|
||||
return configs
|
||||
}, [containerData])
|
||||
}
|
||||
|
||||
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||
export function useYAxisWidth() {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||
let maxChars = 0
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
function updateYAxisWidth(str: string) {
|
||||
if (str.length > maxChars) {
|
||||
maxChars = str.length
|
||||
const div = document.createElement("div")
|
||||
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
|
||||
div.innerHTML = str
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
document.body.appendChild(div)
|
||||
const width = div.offsetWidth + 20
|
||||
if (width > yAxisWidth) {
|
||||
setYAxisWidth(width)
|
||||
}
|
||||
document.body.removeChild(div)
|
||||
})
|
||||
}
|
||||
return str
|
||||
}
|
||||
return { yAxisWidth, updateYAxisWidth }
|
||||
}
|
||||
|
||||
/** Subscribes to the container filter store and returns filtered DataPoints for container charts */
|
||||
export function useContainerDataPoints(
|
||||
chartConfig: ChartConfig,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
|
||||
dataFn: (key: string, data: Record<string, any>) => number | null
|
||||
) {
|
||||
const filter = useStore($containerFilter)
|
||||
const { dataPoints, filteredKeys } = useMemo(() => {
|
||||
const filterTerms = filter
|
||||
? filter
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((term) => term.length > 0)
|
||||
: []
|
||||
const filtered = new Set<string>()
|
||||
const points = Object.keys(chartConfig).map((key) => {
|
||||
const isFiltered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
||||
if (isFiltered) filtered.add(key)
|
||||
return {
|
||||
label: key,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
|
||||
dataKey: (data: Record<string, any>) => dataFn(key, data),
|
||||
color: chartConfig[key].color ?? "",
|
||||
opacity: isFiltered ? 0.05 : 0.4,
|
||||
strokeOpacity: isFiltered ? 0.1 : 1,
|
||||
activeDot: !isFiltered,
|
||||
stackId: "a",
|
||||
}
|
||||
})
|
||||
return {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
|
||||
dataPoints: points as DataPoint<Record<string, any>>[],
|
||||
filteredKeys: filtered,
|
||||
}
|
||||
}, [chartConfig, filter])
|
||||
return { filter, dataPoints, filteredKeys }
|
||||
}
|
||||
|
||||
// Assures consistent colors for network interfaces
|
||||
export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
|
||||
const keys = Object.keys(interfaces ?? {})
|
||||
const sortedKeys = keys.sort((a, b) => (interfaces?.[b]?.[3] ?? 0) - (interfaces?.[a]?.[3] ?? 0))
|
||||
return {
|
||||
length: sortedKeys.length,
|
||||
data: (index = 3) => {
|
||||
return sortedKeys.map((key) => ({
|
||||
label: key,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index],
|
||||
color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`,
|
||||
|
||||
opacity: 0.3,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
xAxis,
|
||||
} from "@/components/ui/chart"
|
||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
|
||||
export type DataPoint<T = SystemStatsRecord> = {
|
||||
label: string
|
||||
dataKey: (data: T) => number | null | undefined
|
||||
color: number | string
|
||||
stackId?: string | number
|
||||
order?: number
|
||||
strokeOpacity?: number
|
||||
activeDot?: boolean
|
||||
}
|
||||
|
||||
export default function LineChartDefault({
|
||||
chartData,
|
||||
customData,
|
||||
max,
|
||||
maxToggled,
|
||||
tickFormatter,
|
||||
contentFormatter,
|
||||
dataPoints,
|
||||
domain,
|
||||
legend,
|
||||
itemSorter,
|
||||
showTotal = false,
|
||||
reverseStackOrder = false,
|
||||
hideYAxis = false,
|
||||
filter,
|
||||
truncate = false,
|
||||
chartProps,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||
customData?: any[]
|
||||
max?: number
|
||||
maxToggled?: boolean
|
||||
tickFormatter: (value: number, index: number) => string
|
||||
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||
contentFormatter: (item: any, key: string) => ReactNode
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
|
||||
dataPoints?: DataPoint<any>[]
|
||||
domain?: AxisDomain
|
||||
legend?: boolean
|
||||
showTotal?: boolean
|
||||
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
|
||||
itemSorter?: (a: any, b: any) => number
|
||||
reverseStackOrder?: boolean
|
||||
hideYAxis?: boolean
|
||||
filter?: string
|
||||
truncate?: boolean
|
||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||
useEffect(() => {
|
||||
const shouldPrimeData = sourceData.length && !displayData.length
|
||||
const sourceChanged = sourceData !== displayData
|
||||
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
|
||||
if (shouldUpdate) {
|
||||
setDisplayData(sourceData)
|
||||
}
|
||||
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||
setDisplayMaxToggled(maxToggled)
|
||||
}
|
||||
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||
|
||||
// Use a stable key derived from data point identities and visual properties
|
||||
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
||||
|
||||
const Lines = useMemo(() => {
|
||||
return dataPoints?.map((dataPoint, i) => {
|
||||
let { color } = dataPoint
|
||||
if (typeof color === "number") {
|
||||
color = `var(--chart-${color})`
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={dataPoint.label}
|
||||
dataKey={dataPoint.dataKey}
|
||||
name={dataPoint.label}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
stroke={color}
|
||||
strokeOpacity={dataPoint.strokeOpacity}
|
||||
isAnimationActive={false}
|
||||
// stackId={dataPoint.stackId}
|
||||
order={dataPoint.order || i}
|
||||
// activeDot={dataPoint.activeDot ?? true}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}, [linesKey, displayMaxToggled])
|
||||
|
||||
return useMemo(() => {
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
// if (logRender) {
|
||||
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||
// }
|
||||
return (
|
||||
<ChartContainer
|
||||
ref={ref}
|
||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||
"opacity-100": yAxisWidth || hideYAxis,
|
||||
"ps-4": hideYAxis,
|
||||
})}
|
||||
>
|
||||
<LineChart
|
||||
reverseStackOrder={reverseStackOrder}
|
||||
accessibilityLayer
|
||||
data={displayData}
|
||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||
{...chartProps}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{!hideYAxis && (
|
||||
<YAxis
|
||||
direction="ltr"
|
||||
orientation={chartData.orientation}
|
||||
className="tracking-tighter"
|
||||
width={yAxisWidth}
|
||||
domain={domain ?? [0, max ?? "auto"]}
|
||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
)}
|
||||
{xAxis(chartData)}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
// @ts-expect-error
|
||||
itemSorter={itemSorter}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||
contentFormatter={contentFormatter}
|
||||
showTotal={showTotal}
|
||||
filter={filter}
|
||||
truncate={truncate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Lines}
|
||||
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}, [displayData, yAxisWidth, filter, Lines])
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { DialogDescription } from "@radix-ui/react-dialog"
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
BookIcon,
|
||||
ContainerIcon,
|
||||
DatabaseBackupIcon,
|
||||
FingerprintIcon,
|
||||
HardDriveIcon,
|
||||
LogsIcon,
|
||||
MailIcon,
|
||||
Server,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import { isAdmin } from "@/lib/api"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { getHostDisplayValue, listen } from "@/lib/utils"
|
||||
import { $router, basePath, navigate, prependBasePath } from "./router"
|
||||
|
||||
export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen(!open)
|
||||
}
|
||||
}
|
||||
return listen(document, "keydown", down)
|
||||
}, [open, setOpen])
|
||||
|
||||
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}>
|
||||
<DialogDescription className="sr-only">Command palette</DialogDescription>
|
||||
<CommandInput placeholder={t`Search for systems or settings...`} />
|
||||
<CommandList>
|
||||
{systems.length > 0 && (
|
||||
<>
|
||||
<CommandGroup>
|
||||
{systems.map((system) => (
|
||||
<CommandItem
|
||||
key={system.id}
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "system", { id: system.id }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Server className="me-2 size-4" />
|
||||
<span className="max-w-60 truncate">{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)
|
||||
}}
|
||||
>
|
||||
<ServerIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>All Systems</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "containers"))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<ContainerIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>All Containers</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "smart"))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<HardDriveIcon className="me-2 size-4" />
|
||||
<span>S.M.A.R.T.</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Settings</Trans>
|
||||
</span>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["alerts"]}
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "notifications" }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<MailIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Notifications</Trans>
|
||||
</span>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={[t`Universal token`]}
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "tokens" }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<FingerprintIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Tokens & Fingerprints</Trans>
|
||||
</span>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
navigate(getPagePath($router, "settings", { name: "alert-history" }))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<AlertOctagonIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Alert History</Trans>
|
||||
</span>
|
||||
{SettingsShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["help", "oauth", "oidc"]}
|
||||
onSelect={() => {
|
||||
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
||||
}}
|
||||
>
|
||||
<BookIcon className="me-2 size-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(prependBasePath("/_/"), "_blank")
|
||||
}}
|
||||
>
|
||||
<UsersIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
</span>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open(prependBasePath("/_/#/logs"), "_blank")
|
||||
}}
|
||||
>
|
||||
<LogsIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Logs</Trans>
|
||||
</span>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
|
||||
}}
|
||||
>
|
||||
<DatabaseBackupIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>Backups</Trans>
|
||||
</span>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["email"]}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
|
||||
}}
|
||||
>
|
||||
<MailIcon className="me-2 size-4" />
|
||||
<span>
|
||||
<Trans>SMTP settings</Trans>
|
||||
</span>
|
||||
{AdminShortcut}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}, [open])
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
||||
import type { ContainerRecord } from "@/types"
|
||||
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
|
||||
import {
|
||||
ClockIcon,
|
||||
ContainerIcon,
|
||||
CpuIcon,
|
||||
LayersIcon,
|
||||
MemoryStickIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "lucide-react"
|
||||
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||
|
||||
// Unit names and their corresponding number of seconds for converting docker status strings
|
||||
const unitSeconds = [
|
||||
["s", 1],
|
||||
["mi", 60],
|
||||
["h", 3600],
|
||||
["d", 86400],
|
||||
["w", 604800],
|
||||
["mo", 2592000],
|
||||
] as const
|
||||
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
|
||||
function getStatusValue(status: string): number {
|
||||
const [_, num, unit] = status.split(" ")
|
||||
// Docker uses "a" or "an" instead of "1" for singular units (e.g., "Up a minute", "Up an hour")
|
||||
const numValue = num === "a" || num === "an" ? 1 : Number(num)
|
||||
for (const [unitName, value] of unitSeconds) {
|
||||
if (unit.startsWith(unitName)) {
|
||||
return numValue * value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorFn: (record) => record.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={ContainerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 xl:w-48 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
return systemNameA.localeCompare(systemNameB)
|
||||
},
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
const longestName = useStore($longestSystemNameLen)
|
||||
return (
|
||||
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{allSystems[getValue() as string]?.name ?? ""}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: "id",
|
||||
// accessorFn: (record) => record.id,
|
||||
// sortingFn: (a, b) => a.original.id.localeCompare(b.original.id),
|
||||
// header: ({ column }) => <HeaderButton column={column} name="ID" Icon={HashIcon} />,
|
||||
// cell: ({ getValue }) => {
|
||||
// return <span className="ms-1.5 me-3 font-mono">{getValue() as string}</span>
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: "cpu",
|
||||
accessorFn: (record) => record.cpu,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
return <span className="ms-1 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
accessorFn: (record) => record.memory,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, true)
|
||||
return (
|
||||
<span className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "net",
|
||||
accessorFn: (record) => record.net,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
|
||||
minSize: 112,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, true, undefined, false)
|
||||
return (
|
||||
<div className="ms-1 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.health,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
|
||||
minSize: 121,
|
||||
cell: ({ getValue }) => {
|
||||
const healthValue = getValue() as number
|
||||
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12">
|
||||
<span
|
||||
className={cn("size-2 me-1.5 rounded-full", {
|
||||
"bg-green-500": healthValue === ContainerHealth.Healthy,
|
||||
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
|
||||
"bg-yellow-500": healthValue === ContainerHealth.Starting,
|
||||
"bg-zinc-500": healthValue === ContainerHealth.None,
|
||||
})}
|
||||
></span>
|
||||
{healthStatus}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
accessorFn: (record) => record.ports || undefined,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton
|
||||
column={column}
|
||||
name={t({ message: "Ports", context: "Container ports" })}
|
||||
Icon={SquareArrowRightEnterIcon}
|
||||
/>
|
||||
),
|
||||
sortingFn: (a, b) => getPortValue(a.original.ports) - getPortValue(b.original.ports),
|
||||
minSize: 147,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as string | undefined
|
||||
if (!val) {
|
||||
return <div className="ms-1.5 text-muted-foreground">-</div>
|
||||
}
|
||||
const className = "ms-1 w-27 block truncate tabular-nums"
|
||||
if (val.length > 14) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className={className}>{val}</TooltipTrigger>
|
||||
<TooltipContent>{val}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return <span className={className}>{val}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
|
||||
accessorFn: (record) => record.image,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as string
|
||||
return (
|
||||
<div className="ms-1 xl:w-40 truncate" title={val}>
|
||||
{val}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (record) => record.status,
|
||||
invertSorting: true,
|
||||
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1 w-25 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return <span className="ms-1 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<ContainerRecord>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert port string to a number for sorting.
|
||||
* Handles formats like "80", "127.0.0.1:80", and "80, 443" (takes the first mapping).
|
||||
*/
|
||||
function getPortValue(ports: string | undefined): number {
|
||||
if (!ports) {
|
||||
return 0
|
||||
}
|
||||
const first = ports.includes(",") ? ports.substring(0, ports.indexOf(",")) : ports
|
||||
const colonIndex = first.lastIndexOf(":")
|
||||
const portStr = colonIndex === -1 ? first : first.substring(colonIndex + 1)
|
||||
return Number(portStr) || 0
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: html comes directly from docker via agent */
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { memo, type RefObject, useEffect, useRef, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { pb } from "@/lib/api"
|
||||
import type { ContainerRecord } from "@/types"
|
||||
import { containerChartCols } from "@/components/containers-table/containers-table-columns"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
|
||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { $router, Link } from "../router"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
|
||||
const syntaxTheme = "github-dark-dimmed"
|
||||
|
||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-c-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
// Hide ports column if no ports are present
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const hasPorts = data.some((container) => container.ports)
|
||||
setColumnVisibility((prev) => {
|
||||
if (prev.ports === hasPorts) {
|
||||
return prev
|
||||
}
|
||||
return { ...prev, ports: hasPorts }
|
||||
})
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<ContainerRecord>("containers")
|
||||
.getList(0, 2000, {
|
||||
fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated",
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then(({ items }) => {
|
||||
if (items.length === 0) {
|
||||
setData((curItems) => {
|
||||
if (systemId) {
|
||||
return curItems?.filter((item) => item.system !== systemId) ?? []
|
||||
}
|
||||
return []
|
||||
})
|
||||
return
|
||||
}
|
||||
setData((curItems) => {
|
||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||
const containerIds = new Set()
|
||||
const newItems: ContainerRecord[] = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
containerIds.add(item.id)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems ?? []) {
|
||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull system containers after every system update
|
||||
if (!systemId) {
|
||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// exclude initial load of systems
|
||||
if (Date.now() - loadTime > 500) {
|
||||
fetchData(systemId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// if systemId, fetch containers after the system is updated
|
||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
fetchData(systemId)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const container = row.original
|
||||
const systemName = $allSystemsById.get()[container.system]?.name ?? ""
|
||||
const id = container.id ?? ""
|
||||
const name = container.name ?? ""
|
||||
const status = container.status ?? ""
|
||||
const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? ""
|
||||
const image = container.image ?? ""
|
||||
const ports = container.ports ?? ""
|
||||
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image} ${ports}`.toLowerCase()
|
||||
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
return (
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Containers</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a container to view more information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ps-4 pe-10 w-full"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllContainersTable = memo(function AllContainersTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
}: {
|
||||
table: TableType<ContainerRecord>
|
||||
rows: Row<ContainerRecord>[]
|
||||
colLength: number
|
||||
data: ContainerRecord[] | undefined
|
||||
}) {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeContainer = useRef<ContainerRecord | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const openSheet = (container: ContainerRecord) => {
|
||||
activeContainer.current = container
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<ContainersTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <ContainerTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
||||
try {
|
||||
const [{ highlighter }, logsHtml] = await Promise.all([
|
||||
import("@/lib/shiki"),
|
||||
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
||||
system: container.system,
|
||||
container: container.id,
|
||||
}),
|
||||
])
|
||||
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
||||
try {
|
||||
let [{ highlighter }, { info }] = await Promise.all([
|
||||
import("@/lib/shiki"),
|
||||
pb.send<{ info: string }>("/api/beszel/containers/info", {
|
||||
system: container.system,
|
||||
container: container.id,
|
||||
}),
|
||||
])
|
||||
try {
|
||||
info = JSON.stringify(JSON.parse(info), null, 2)
|
||||
} catch (_) {}
|
||||
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function ContainerSheet({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
activeContainer,
|
||||
}: {
|
||||
sheetOpen: boolean
|
||||
setSheetOpen: (open: boolean) => void
|
||||
activeContainer: RefObject<ContainerRecord | null>
|
||||
}) {
|
||||
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
||||
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
||||
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
||||
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const container = activeContainer.current
|
||||
|
||||
function scrollLogsToBottom() {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
if (!container) return
|
||||
setIsRefreshingLogs(true)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const logsHtml = await getLogsHtml(container)
|
||||
setLogsDisplay(logsHtml)
|
||||
setTimeout(scrollLogsToBottom, 20)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
// Ensure minimum spin duration of 800ms
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, 500 - elapsed)
|
||||
setTimeout(() => {
|
||||
setIsRefreshingLogs(false)
|
||||
}, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLogsDisplay("")
|
||||
setInfoDisplay("")
|
||||
if (!container) return
|
||||
;(async () => {
|
||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||
setLogsDisplay(logsHtml)
|
||||
setInfoDisplay(infoHtml)
|
||||
setTimeout(scrollLogsToBottom, 20)
|
||||
})()
|
||||
}, [container])
|
||||
|
||||
if (!container) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogsFullscreenDialog
|
||||
open={logsFullscreenOpen}
|
||||
onOpenChange={setLogsFullscreenOpen}
|
||||
logsDisplay={logsDisplay}
|
||||
containerName={container.name}
|
||||
onRefresh={refreshLogs}
|
||||
isRefreshing={isRefreshingLogs}
|
||||
/>
|
||||
<InfoFullscreenDialog
|
||||
open={infoFullscreenOpen}
|
||||
onOpenChange={setInfoFullscreenOpen}
|
||||
infoDisplay={infoDisplay}
|
||||
containerName={container.name}
|
||||
/>
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-220 p-2">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{container.name}</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
|
||||
{$allSystemsById.get()[container.system]?.name ?? ""}
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{container.status}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{container.image}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{container.id}
|
||||
{/* {container.ports && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{container.ports}
|
||||
</>
|
||||
)} */}
|
||||
{/* <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{ContainerHealthLabels[container.health as ContainerHealth]} */}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
|
||||
<div className="flex items-center w-full">
|
||||
<h3>{t`Logs`}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refreshLogs}
|
||||
className="h-8 w-8 p-0 ms-auto"
|
||||
disabled={isRefreshingLogs}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
|
||||
<MaximizeIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className={cn(
|
||||
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
||||
!logsDisplay && ["animate-pulse", "h-full"]
|
||||
)}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<h3>{t`Detail`}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setInfoFullscreenOpen(true)}
|
||||
className="h-8 w-8 p-0 ms-auto"
|
||||
>
|
||||
<MaximizeIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
||||
!infoDisplay && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id} style={{ width: header.getSize() }}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const ContainerTableRow = memo(function ContainerTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<ContainerRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (container: ContainerRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0 ps-4.5"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
||||
function LogsFullscreenDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
logsDisplay,
|
||||
containerName,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
logsDisplay: string
|
||||
containerName: string
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing: boolean
|
||||
}) {
|
||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && logsDisplay) {
|
||||
// Scroll the outer container to bottom
|
||||
const scrollToBottom = () => {
|
||||
if (outerContainerRef.current) {
|
||||
outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight
|
||||
}
|
||||
}
|
||||
setTimeout(scrollToBottom, 50)
|
||||
}
|
||||
}, [open, logsDisplay])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||
<DialogTitle className="sr-only">{containerName} logs</DialogTitle>
|
||||
<div ref={outerContainerRef} className="h-full overflow-auto">
|
||||
<div className="h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
|
||||
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
||||
disabled={isRefreshing}
|
||||
title={t`Refresh`}
|
||||
aria-label={t`Refresh`}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 transition-transform duration-300 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoFullscreenDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
infoDisplay,
|
||||
containerName,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
infoDisplay: string
|
||||
containerName: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||
<DialogTitle className="sr-only">{containerName} info</DialogTitle>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="h-full w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm leading-relaxed">
|
||||
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { $copyContent } from "@/lib/stores"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
|
||||
export default function CopyToClipboard({ content }: { content: string }) {
|
||||
return (
|
||||
<Dialog defaultOpen={true}>
|
||||
<DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Copy text</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden xs:block">
|
||||
<Trans>Automatic copy requires a secure context.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyTextarea content={content} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyTextarea({ content }: { content: string }) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return content.split("\n").length
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.select()
|
||||
}
|
||||
}, [textareaRef])
|
||||
|
||||
useEffect(() => {
|
||||
return () => $copyContent.set("")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className="font-mono overflow-hidden whitespace-pre"
|
||||
rows={rows}
|
||||
value={content}
|
||||
readOnly
|
||||
ref={textareaRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
createDomain,
|
||||
updateDomain,
|
||||
lookupDomain,
|
||||
cleanDomain,
|
||||
type Domain,
|
||||
type CreateDomainRequest,
|
||||
type UpdateDomainRequest,
|
||||
type DomainLookupResult,
|
||||
} from "@/lib/domains"
|
||||
import { Loader2, Search } from "lucide-react"
|
||||
|
||||
const formSchema = z.object({
|
||||
domain_name: z.string().min(1, "Domain name is required"),
|
||||
tags: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
purchase_price: z.coerce.number().min(0).optional(),
|
||||
current_value: z.coerce.number().min(0).optional(),
|
||||
renewal_cost: z.coerce.number().min(0).optional(),
|
||||
auto_renew: z.boolean(),
|
||||
alert_days_before: z.coerce.number().min(1).max(365),
|
||||
ssl_alert_enabled: z.boolean(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
interface DomainDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
domain?: Domain | null
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: DomainDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
const [lookupData, setLookupData] = useState<DomainLookupResult | null>(null)
|
||||
const [isLookingUp, setIsLookingUp] = useState(false)
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
domain_name: "",
|
||||
tags: "",
|
||||
notes: "",
|
||||
purchase_price: 0,
|
||||
current_value: 0,
|
||||
renewal_cost: 0,
|
||||
auto_renew: false,
|
||||
alert_days_before: 30,
|
||||
ssl_alert_enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isEdit && domain) {
|
||||
form.reset({
|
||||
domain_name: domain.domain_name,
|
||||
tags: domain.tags?.join(", ") || "",
|
||||
notes: domain.notes || "",
|
||||
purchase_price: domain.purchase_price || 0,
|
||||
current_value: domain.current_value || 0,
|
||||
renewal_cost: domain.renewal_cost || 0,
|
||||
auto_renew: domain.auto_renew || false,
|
||||
alert_days_before: domain.alert_days_before || 30,
|
||||
ssl_alert_enabled: domain.ssl_alert_enabled || true,
|
||||
})
|
||||
} else if (open && !isEdit) {
|
||||
form.reset({
|
||||
domain_name: "",
|
||||
tags: "",
|
||||
notes: "",
|
||||
purchase_price: 0,
|
||||
current_value: 0,
|
||||
renewal_cost: 0,
|
||||
auto_renew: false,
|
||||
alert_days_before: 30,
|
||||
ssl_alert_enabled: true,
|
||||
})
|
||||
setLookupData(null)
|
||||
}
|
||||
}, [open, isEdit, domain, form])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createDomain,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Domain added successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["domains"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to add domain",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateDomainRequest }) =>
|
||||
updateDomain(id, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Domain updated successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["domains"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to update domain",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleLookup = async () => {
|
||||
const domainName = form.getValues("domain_name")
|
||||
if (!domainName) return
|
||||
|
||||
setIsLookingUp(true)
|
||||
try {
|
||||
const data = await lookupDomain(domainName)
|
||||
setLookupData(data)
|
||||
toast({ title: "Domain info retrieved successfully" })
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to lookup domain",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLookingUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const payload: CreateDomainRequest = {
|
||||
domain_name: cleanDomain(data.domain_name),
|
||||
auto_lookup: !isEdit && lookupData !== null,
|
||||
tags: data.tags?.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
notes: data.notes,
|
||||
purchase_price: data.purchase_price,
|
||||
current_value: data.current_value,
|
||||
renewal_cost: data.renewal_cost,
|
||||
auto_renew: data.auto_renew,
|
||||
alert_days_before: data.alert_days_before,
|
||||
ssl_alert_enabled: data.ssl_alert_enabled,
|
||||
}
|
||||
|
||||
if (isEdit && domain) {
|
||||
updateMutation.mutate({
|
||||
id: domain.id,
|
||||
data: {
|
||||
tags: payload.tags,
|
||||
notes: payload.notes,
|
||||
purchase_price: payload.purchase_price,
|
||||
current_value: payload.current_value,
|
||||
renewal_cost: payload.renewal_cost,
|
||||
auto_renew: payload.auto_renew,
|
||||
alert_days_before: payload.alert_days_before,
|
||||
ssl_alert_enabled: payload.ssl_alert_enabled,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit Domain" : "Add Domain"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update domain tracking settings."
|
||||
: "Add a domain to track its expiry, SSL, and DNS information."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="valuation">Valuation</TabsTrigger>
|
||||
<TabsTrigger value="alerts">Alerts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
{!isEdit && (
|
||||
<div className="flex gap-2 items-end">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain_name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Domain Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLookup}
|
||||
disabled={isLookingUp || !form.getValues("domain_name")}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isLookingUp ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
<span>Lookup</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tags (comma separated)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="portfolio, client, investment" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Any additional information..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{lookupData && !isEdit && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium">Lookup Results</h4>
|
||||
{lookupData.registrar_name && (
|
||||
<p className="text-sm">Registrar: {lookupData.registrar_name}</p>
|
||||
)}
|
||||
{lookupData.expiry_date && (
|
||||
<p className="text-sm">Expires: {lookupData.expiry_date}</p>
|
||||
)}
|
||||
{lookupData.ssl_valid_to && (
|
||||
<p className="text-sm">SSL Expires: {lookupData.ssl_valid_to}</p>
|
||||
)}
|
||||
{lookupData.host_country && (
|
||||
<p className="text-sm">Location: {lookupData.host_country}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="valuation" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="purchase_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Purchase Price</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="current_value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="renewal_cost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Renewal Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_renew"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Auto Renew</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alerts" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alert_days_before"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alert Days Before Expiry</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} max={365} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssl_alert_enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>SSL Expiry Alerts</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEdit ? "Update" : "Add Domain"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
getDomains,
|
||||
deleteDomain,
|
||||
refreshDomain,
|
||||
getStatusBadgeColor,
|
||||
getStatusLabel,
|
||||
formatDate,
|
||||
formatDays,
|
||||
cleanDomain,
|
||||
type Domain,
|
||||
} from "@/lib/domains"
|
||||
import { MoreHorizontal, Plus, RefreshCw, Globe, AlertTriangle, CheckCircle2, Clock } from "lucide-react"
|
||||
import { DomainDialog } from "./domain-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
|
||||
export default function DomainsTable() {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingDomain, setEditingDomain] = useState<Domain | null>(null)
|
||||
|
||||
const { data: domains, isLoading } = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: getDomains,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDomain,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Domain deleted successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["domains"] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to delete domain",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: refreshDomain,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Domain refresh started" })
|
||||
queryClient.invalidateQueries({ queryKey: ["domains"] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to refresh domain",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleEdit = (domain: Domain) => {
|
||||
setEditingDomain(domain)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingDomain(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this domain?")) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
refreshMutation.mutate(id)
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case "expiring":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />
|
||||
case "expired":
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
default:
|
||||
return <Globe className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Domain Expiry Monitoring</h2>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Expiry</TableHead>
|
||||
<TableHead>Days Left</TableHead>
|
||||
<TableHead>Registrar</TableHead>
|
||||
<TableHead>SSL Expiry</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No domains tracked. Add domains to monitor their expiry dates.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
domains?.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/domain/${domain.id}`} className="flex items-center gap-2 cursor-pointer">
|
||||
{domain.favicon_url && (
|
||||
<img
|
||||
src={domain.favicon_url}
|
||||
alt=""
|
||||
className="h-4 w-4"
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
></img>
|
||||
)}
|
||||
<span className="hover:underline">{domain.domain_name}</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(domain.status)}
|
||||
<Badge className={getStatusBadgeColor(domain.status)}>
|
||||
{getStatusLabel(domain.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={
|
||||
domain.days_until_expiry !== undefined && domain.days_until_expiry <= 30
|
||||
? domain.days_until_expiry <= 7
|
||||
? "text-red-600 font-semibold"
|
||||
: "text-yellow-600"
|
||||
: ""
|
||||
}>
|
||||
{formatDays(domain.days_until_expiry)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{domain.registrar_name || "Unknown"}</TableCell>
|
||||
<TableCell>
|
||||
{domain.ssl_valid_to ? (
|
||||
<span
|
||||
className={
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14
|
||||
? "text-red-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{formatDays(domain.ssl_days_until)}
|
||||
</span>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(domain)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`https://${domain.domain_name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
Visit
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(domain.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DomainDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
domain={editingDomain}
|
||||
isEdit={!!editingDomain}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { GithubIcon } from "lucide-react"
|
||||
import { $newVersion } from "@/lib/stores"
|
||||
import { Separator } from "./ui/separator"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
|
||||
export function FooterRepoLink() {
|
||||
const newVersion = useStore($newVersion)
|
||||
return (
|
||||
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
||||
<a
|
||||
href="https://github.com/henrygd/beszel"
|
||||
target="_blank"
|
||||
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
|
||||
rel="noopener"
|
||||
>
|
||||
<GithubIcon className="h-3 w-3" /> GitHub
|
||||
</a>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<a
|
||||
href="https://github.com/henrygd/beszel/releases"
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground duration-75"
|
||||
rel="noopener"
|
||||
>
|
||||
Beszel {globalThis.BESZEL.HUB_VERSION}
|
||||
</a>
|
||||
{newVersion?.v && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<a
|
||||
href={newVersion.url}
|
||||
target="_blank"
|
||||
className="text-yellow-500 hover:text-yellow-400 duration-75"
|
||||
rel="noopener"
|
||||
>
|
||||
<Trans context="New version available">{newVersion.v} available</Trans>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { i18n } from "@lingui/core"
|
||||
import { memo } from "react"
|
||||
import { copyToClipboard, getHubURL } from "@/lib/utils"
|
||||
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
|
||||
|
||||
// const isbeta = beszel.hub_version.includes("beta")
|
||||
// const imagetag = isbeta ? ":edge" : ""
|
||||
|
||||
/**
|
||||
* 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 = "") => {
|
||||
return `https://get.beszel.dev${path}`
|
||||
// no beta for now
|
||||
// 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
|
||||
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`
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { LanguagesIcon } from "lucide-react"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { dynamicActivate } from "@/lib/i18n"
|
||||
import languages from "@/lib/languages"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
|
||||
export function LangToggle() {
|
||||
const { i18n } = useLingui()
|
||||
|
||||
const LangTrans = <Trans>Language</Trans>
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
||||
<span className="sr-only">{LangTrans}</span>
|
||||
<TooltipContent>{LangTrans}</TooltipContent>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent className="grid grid-cols-3">
|
||||
{languages.map(([lang, label, e]) => (
|
||||
<DropdownMenuItem
|
||||
key={lang}
|
||||
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
|
||||
onClick={() => dynamicActivate(lang)}
|
||||
>
|
||||
<span>
|
||||
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
|
||||
</span>{" "}
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Tooltip>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { KeyIcon, LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
||||
import type { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import * as v from "valibot"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $authenticated } from "@/lib/stores"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { $router, Link, basePath, prependBasePath } from "../router"
|
||||
import { toast } from "../ui/use-toast"
|
||||
import { OtpInputForm } from "./otp-forms"
|
||||
|
||||
const honeypot = v.literal("")
|
||||
const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
|
||||
const passwordSchema = v.pipe(
|
||||
v.string(),
|
||||
v.minLength(8, t`Password must be at least 8 characters.`),
|
||||
v.maxBytes(72, t`Password must be less than 72 bytes.`)
|
||||
)
|
||||
|
||||
const LoginSchema = v.looseObject({
|
||||
website: honeypot,
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
})
|
||||
|
||||
const RegisterSchema = v.looseObject({
|
||||
website: honeypot,
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
passwordConfirm: passwordSchema,
|
||||
})
|
||||
|
||||
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||
toast({
|
||||
title: t`Login attempt failed`,
|
||||
description,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
const getAuthProviderIcon = (provider: AuthProviderInfo) => {
|
||||
let { name } = provider
|
||||
if (name.startsWith("oidc")) {
|
||||
name = "oidc"
|
||||
}
|
||||
return prependBasePath(`/_/images/oauth2/${name}.svg`)
|
||||
}
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
isFirstRun,
|
||||
authMethods,
|
||||
...props
|
||||
}: {
|
||||
className?: string
|
||||
isFirstRun: boolean
|
||||
authMethods: AuthMethodsList
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [isOauthLoading, setIsOauthLoading] = useState<boolean>(false)
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
|
||||
const [mfaId, setMfaId] = useState<string | undefined>()
|
||||
const [otpId, setOtpId] = useState<string | undefined>()
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
// store email for later use if mfa is enabled
|
||||
let email = ""
|
||||
try {
|
||||
const formData = new FormData(e.target as HTMLFormElement)
|
||||
const data = Object.fromEntries(formData) as Record<string, any>
|
||||
const Schema = isFirstRun ? RegisterSchema : LoginSchema
|
||||
const result = v.safeParse(Schema, data)
|
||||
if (!result.success) {
|
||||
console.log(result)
|
||||
const errors = {}
|
||||
for (const issue of result.issues) {
|
||||
// @ts-expect-error
|
||||
errors[issue.path[0].key] = issue.message
|
||||
}
|
||||
setErrors(errors)
|
||||
return
|
||||
}
|
||||
const { password, passwordConfirm } = result.output
|
||||
email = result.output.email
|
||||
if (isFirstRun) {
|
||||
// check that passwords match
|
||||
if (password !== passwordConfirm) {
|
||||
const msg = "Passwords do not match"
|
||||
setErrors({ passwordConfirm: msg })
|
||||
return
|
||||
}
|
||||
await pb.send("/api/beszel/create-user", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
await pb.collection("users").authWithPassword(email, password)
|
||||
} else {
|
||||
await pb.collection("users").authWithPassword(email, password)
|
||||
}
|
||||
$authenticated.set(true)
|
||||
} catch (err: any) {
|
||||
const mfaId = err?.response?.mfaId
|
||||
if (!mfaId) {
|
||||
showLoginFaliedToast()
|
||||
throw err
|
||||
}
|
||||
setMfaId(mfaId)
|
||||
try {
|
||||
const { otpId } = await pb.collection("users").requestOTP(email)
|
||||
setOtpId(otpId)
|
||||
} catch (err) {
|
||||
console.log({ err })
|
||||
showLoginFaliedToast()
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[isFirstRun]
|
||||
)
|
||||
|
||||
const authProviders = authMethods.oauth2.providers ?? []
|
||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||
const passwordEnabled = authMethods.password.enabled
|
||||
const otpEnabled = authMethods.otp.enabled
|
||||
const mfaEnabled = authMethods.mfa.enabled
|
||||
|
||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||
setIsOauthLoading(true)
|
||||
|
||||
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||
redirectToOauthProvider(provider)
|
||||
return
|
||||
}
|
||||
|
||||
const oAuthOpts: OAuth2AuthConfig = {
|
||||
provider: provider.name,
|
||||
}
|
||||
// https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061
|
||||
if (forcePopup || navigator.userAgent.match(/iPhone|iPad|iPod/i)) {
|
||||
const authWindow = window.open()
|
||||
if (!authWindow) {
|
||||
setIsOauthLoading(false)
|
||||
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||
return
|
||||
}
|
||||
oAuthOpts.urlCallback = (url) => {
|
||||
authWindow.location.href = url
|
||||
}
|
||||
}
|
||||
pb.collection("users")
|
||||
.authWithOAuth2(oAuthOpts)
|
||||
.then(() => {
|
||||
$authenticated.set(pb.authStore.isValid)
|
||||
})
|
||||
.catch(showLoginFaliedToast)
|
||||
.finally(() => {
|
||||
setIsOauthLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the OAuth provider's authentication page in the same window.
|
||||
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
|
||||
*/
|
||||
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||
const url = new URL(provider.authURL)
|
||||
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// handle redirect-based OAuth callback if we have a code
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get("code")
|
||||
if (code) {
|
||||
const state = params.get("state")
|
||||
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||
if (!state || provider.state !== state) {
|
||||
showLoginFaliedToast()
|
||||
} else {
|
||||
setIsOauthLoading(true)
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
pb.collection("users")
|
||||
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
|
||||
.finally(() => setIsOauthLoading(false))
|
||||
}
|
||||
}
|
||||
|
||||
// auto login if password disabled and only one auth provider
|
||||
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||
// Add a small timeout to ensure browser is ready to handle popups
|
||||
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
|
||||
return
|
||||
}
|
||||
|
||||
// refresh auth if not in above states (required for trusted auth header)
|
||||
pb.collection("users")
|
||||
.authRefresh()
|
||||
.then((res) => {
|
||||
pb.authStore.save(res.token, res.record)
|
||||
$authenticated.set(!!pb.authStore.isValid)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (otpId && mfaId) {
|
||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
{passwordEnabled && (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||
<div className="grid gap-2.5">
|
||||
<div className="grid gap-1 relative">
|
||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="name@example.com"
|
||||
type="text"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isOauthLoading}
|
||||
className={cn("ps-9", errors?.email && "border-red-500")}
|
||||
/>
|
||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
||||
</div>
|
||||
<div className="grid gap-1 relative">
|
||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="pass">
|
||||
<Trans>Password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="pass"
|
||||
name="password"
|
||||
placeholder={t`Password`}
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading || isOauthLoading}
|
||||
className={cn("ps-9", errors?.password && "border-red-500")}
|
||||
/>
|
||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||
</div>
|
||||
{isFirstRun && (
|
||||
<div className="grid gap-1 relative">
|
||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="pass2">
|
||||
<Trans>Confirm password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="pass2"
|
||||
name="passwordConfirm"
|
||||
placeholder={t`Confirm password`}
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading || isOauthLoading}
|
||||
className={cn("ps-9", errors?.password && "border-red-500")}
|
||||
/>
|
||||
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="sr-only">
|
||||
{/* honeypot */}
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
data-protonpass-ignore
|
||||
/>
|
||||
</div>
|
||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogInIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
{isFirstRun ? t`Create account` : t`Sign in`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{(isFirstRun || oauthEnabled || (otpEnabled && !mfaEnabled)) && (
|
||||
// only show 'continue with' during onboarding or if we have auth providers
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* hide OTP button if MFA is enabled (it will be used as MFA) */}
|
||||
{otpEnabled && !mfaEnabled && (
|
||||
<div className="grid gap-2 -mt-1">
|
||||
<Link href="/request-otp" type="button" className={cn(buttonVariants({ variant: "outline" }), "flex gap-2")}>
|
||||
<KeyIcon className="size-4" />
|
||||
<Trans>One-time password</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{oauthEnabled && (
|
||||
<div className="grid gap-2 -mt-1">
|
||||
{authMethods.oauth2.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.name}
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }), {
|
||||
"justify-self-center": !passwordEnabled,
|
||||
"px-5": !passwordEnabled,
|
||||
})}
|
||||
onClick={() => loginWithOauth(provider)}
|
||||
disabled={isLoading || isOauthLoading}
|
||||
>
|
||||
{isOauthLoading ? (
|
||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<img
|
||||
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||
src={getAuthProviderIcon(provider)}
|
||||
alt=""
|
||||
// onError={(e) => {
|
||||
// e.currentTarget.src = "/static/lock.svg"
|
||||
// }}
|
||||
/>
|
||||
)}
|
||||
<span className="translate-y-px">{provider.displayName}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!oauthEnabled && isFirstRun && (
|
||||
// only show GitHub button / dialog during onboarding
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
|
||||
<span className="translate-y-px">GitHub</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>OAuth 2 / OIDC support</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-primary/70 text-[0.95em] contents">
|
||||
<p>
|
||||
<Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>
|
||||
Please see{" "}
|
||||
<a
|
||||
href="https://beszel.dev/guide/oauth"
|
||||
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
||||
>
|
||||
the documentation
|
||||
</a>{" "}
|
||||
for instructions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{passwordEnabled && !isFirstRun && (
|
||||
<Link
|
||||
href={getPagePath($router, "forgot_password")}
|
||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trans>Forgot password?</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { pb } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "../ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"
|
||||
import { Input } from "../ui/input"
|
||||
import { Label } from "../ui/label"
|
||||
import { toast } from "../ui/use-toast"
|
||||
|
||||
const showLoginFaliedToast = () => {
|
||||
toast({
|
||||
title: t`Login attempt failed`,
|
||||
description: t`Please check your credentials and try again`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [email, setEmail] = useState("")
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// console.log(email)
|
||||
await pb.collection("users").requestPasswordReset(email)
|
||||
toast({
|
||||
title: t`Password reset request received`,
|
||||
description: t`Check ${email} for a reset link.`,
|
||||
})
|
||||
} catch (e) {
|
||||
showLoginFaliedToast()
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setEmail("")
|
||||
}
|
||||
},
|
||||
[email]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-1 relative">
|
||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
className="ps-9"
|
||||
/>
|
||||
</div>
|
||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizonalIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Reset Password</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<Trans>Command line instructions</Trans>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[41em]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Command line instructions</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||
<Trans>
|
||||
If you've lost the password to your admin account, you may reset it using the following command.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
|
||||
</p>
|
||||
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
||||
./beszel superuser upsert user@example.com password
|
||||
</code>
|
||||
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
||||
docker exec beszel /beszel superuser upsert name@example.com password
|
||||
</code>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import type { AuthMethodsList } from "pocketbase"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { UserAuthForm } from "@/components/login/auth-form"
|
||||
import { pb } from "@/lib/api"
|
||||
import { Logo } from "../logo"
|
||||
import { ModeToggle } from "../mode-toggle"
|
||||
import { $router } from "../router"
|
||||
import { useTheme } from "../theme-provider"
|
||||
import ForgotPassword from "./forgot-pass-form"
|
||||
import { OtpRequestForm } from "./otp-forms"
|
||||
|
||||
export default function () {
|
||||
const page = useStore($router)
|
||||
const [isFirstRun, setFirstRun] = useState(false)
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t`Login` + " / Beszel"
|
||||
|
||||
pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
|
||||
setFirstRun(firstRun)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
pb.collection("users")
|
||||
.listAuthMethods()
|
||||
.then((methods) => {
|
||||
setAuthMethods(methods)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isFirstRun) {
|
||||
return t`Please create an admin account`
|
||||
} else if (page?.route === "forgot_password") {
|
||||
return t`Enter email address to reset password`
|
||||
} else if (page?.route === "request_otp") {
|
||||
return t`Request a one-time password`
|
||||
} else {
|
||||
return t`Please sign in to your account`
|
||||
}
|
||||
}, [isFirstRun, page])
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-svh grid items-center py-12">
|
||||
<div
|
||||
className="grid gap-5 w-full px-4 mx-auto"
|
||||
// @ts-expect-error
|
||||
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
||||
>
|
||||
<div className="absolute top-3 right-3">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="mb-3">
|
||||
<Logo className="h-7 fill-foreground mx-auto" />
|
||||
<span className="sr-only">Beszel</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{page?.route === "forgot_password" ? (
|
||||
<ForgotPassword />
|
||||
) : page?.route === "request_otp" ? (
|
||||
<OtpRequestForm />
|
||||
) : (
|
||||
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/otp"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $authenticated } from "@/lib/stores"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { $router } from "../router"
|
||||
import { buttonVariants } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Label } from "../ui/label"
|
||||
import { showLoginFaliedToast } from "./auth-form"
|
||||
|
||||
export function OtpInputForm({ otpId, mfaId }: { otpId: string; mfaId: string }) {
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
if (value.length === 6) {
|
||||
pb.collection("users")
|
||||
.authWithOTP(otpId, value, { mfaId })
|
||||
.then(() => {
|
||||
$router.open("/")
|
||||
$authenticated.set(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
showLoginFaliedToast(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 items-center justify-center">
|
||||
<InputOTP maxLength={6} value={value} onChange={setValue} autoFocus>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<InputOTPSlot key={i} index={i} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<Trans>Enter your one-time password.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OtpRequestForm() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [email, setEmail] = useState("")
|
||||
const [otpId, setOtpId] = useState<string | undefined>()
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// console.log(email)
|
||||
const { otpId } = await pb.collection("users").requestOTP(email)
|
||||
setOtpId(otpId)
|
||||
} catch (e: any) {
|
||||
showLoginFaliedToast(e?.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setEmail("")
|
||||
}
|
||||
},
|
||||
[email]
|
||||
)
|
||||
|
||||
if (otpId) {
|
||||
return <OtpInputForm otpId={otpId} mfaId={""} />
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-1 relative">
|
||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
className="ps-9"
|
||||
/>
|
||||
</div>
|
||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizonalIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Request OTP</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useId } from "react"
|
||||
|
||||
const d = "M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
||||
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
// Righteous font from Google Fonts
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="20%" x2="100%" y2="120%">
|
||||
<stop offset="10%" style={{ stopColor: "#747bff" }} />
|
||||
<stop offset="90%" style={{ stopColor: "#24eb5c" }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
className="duration-250 group-hover:opacity-0 group-hover:ease-in ease-out"
|
||||
d={d}
|
||||
/>
|
||||
<path
|
||||
className="opacity-0 duration-250 group-hover:opacity-100 ease-in-out"
|
||||
fill={`url(#${id})`}
|
||||
d={d}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const themes = ["light", "dark", "system"] as const
|
||||
const icons = [SunIcon, MoonStarIcon, SunMoonIcon] as const
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const currentIndex = themes.indexOf(theme)
|
||||
const Icon = icons[currentIndex]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size="icon"
|
||||
aria-label={t`Switch theme`}
|
||||
onClick={() => setTheme(themes[(currentIndex + 1) % themes.length])}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"animate-in fade-in spin-in-[-30deg] duration-200",
|
||||
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Switch theme</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
createMonitor,
|
||||
updateMonitor,
|
||||
type Monitor,
|
||||
type MonitorType,
|
||||
type CreateMonitorRequest,
|
||||
type UpdateMonitorRequest,
|
||||
} from "@/lib/monitors"
|
||||
|
||||
const MONITOR_TYPES: { value: MonitorType; label: string }[] = [
|
||||
{ value: "http", label: "HTTP" },
|
||||
{ value: "https", label: "HTTPS" },
|
||||
{ value: "tcp", label: "TCP Port" },
|
||||
{ value: "ping", label: "Ping" },
|
||||
{ value: "dns", label: "DNS" },
|
||||
{ value: "keyword", label: "HTTP Keyword" },
|
||||
{ value: "json-query", label: "HTTP JSON" },
|
||||
{ value: "docker", label: "Docker Container" },
|
||||
]
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]
|
||||
|
||||
const DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SRV"]
|
||||
|
||||
interface AddMonitorDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
monitor?: Monitor | null
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export function AddMonitorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
monitor,
|
||||
isEdit = false,
|
||||
}: AddMonitorDialogProps) {
|
||||
const { t } = useLingui()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState<MonitorType>("https")
|
||||
const [url, setUrl] = useState("")
|
||||
const [hostname, setHostname] = useState("")
|
||||
const [port, setPort] = useState<number | "">("")
|
||||
const [method, setMethod] = useState("GET")
|
||||
const [headers, setHeaders] = useState("")
|
||||
const [body, setBody] = useState("")
|
||||
const [interval, setInterval] = useState(60)
|
||||
const [timeout, setTimeout] = useState(30)
|
||||
const [retries, setRetries] = useState(1)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [jsonQuery, setJsonQuery] = useState("")
|
||||
const [expectedValue, setExpectedValue] = useState("")
|
||||
const [invertKeyword, setInvertKeyword] = useState(false)
|
||||
const [dnsResolveServer, setDnsResolveServer] = useState("")
|
||||
const [dnsResolverMode, setDnsResolverMode] = useState("A")
|
||||
const [description, setDescription] = useState("")
|
||||
const [ignoreTLSError, setIgnoreTLSError] = useState(false)
|
||||
const [certExpiryNotification, setCertExpiryNotification] = useState(false)
|
||||
const [certExpiryDays, setCertExpiryDays] = useState(14)
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (isEdit && monitor) {
|
||||
// Populate form for editing
|
||||
setName(monitor.name)
|
||||
setType(monitor.type)
|
||||
setUrl(monitor.url || "")
|
||||
setHostname(monitor.hostname || "")
|
||||
setPort(monitor.port || "")
|
||||
setMethod(monitor.method || "GET")
|
||||
setHeaders("") // Parse from JSON if needed
|
||||
setBody("")
|
||||
setInterval(monitor.interval || 60)
|
||||
setTimeout(monitor.timeout || 30)
|
||||
setRetries(monitor.retries || 1)
|
||||
setKeyword(monitor.keyword || "")
|
||||
setJsonQuery(monitor.json_query || "")
|
||||
setExpectedValue(monitor.expected_value || "")
|
||||
setInvertKeyword(monitor.invert_keyword || false)
|
||||
setDnsResolveServer(monitor.dns_resolve_server || "")
|
||||
setDnsResolverMode(monitor.dns_resolver_mode || "A")
|
||||
setDescription(monitor.description || "")
|
||||
setIgnoreTLSError(monitor.ignore_tls_error || false)
|
||||
setCertExpiryNotification(monitor.cert_expiry_notification || false)
|
||||
setCertExpiryDays(monitor.cert_expiry_days || 14)
|
||||
} else {
|
||||
// Reset to defaults for new monitor
|
||||
setName("")
|
||||
setType("https")
|
||||
setUrl("")
|
||||
setHostname("")
|
||||
setPort("")
|
||||
setMethod("GET")
|
||||
setHeaders("")
|
||||
setBody("")
|
||||
setInterval(60)
|
||||
setTimeout(30)
|
||||
setRetries(1)
|
||||
setKeyword("")
|
||||
setJsonQuery("")
|
||||
setExpectedValue("")
|
||||
setInvertKeyword(false)
|
||||
setDnsResolveServer("")
|
||||
setDnsResolverMode("A")
|
||||
setDescription("")
|
||||
setIgnoreTLSError(false)
|
||||
setCertExpiryNotification(false)
|
||||
setCertExpiryDays(14)
|
||||
}
|
||||
setActiveTab("basic")
|
||||
}
|
||||
}, [open, isEdit, monitor])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createMonitor,
|
||||
onSuccess: () => {
|
||||
toast({ title: t`Monitor created successfully` })
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t`Failed to create monitor`,
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) =>
|
||||
updateMonitor(id, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: t`Monitor updated successfully` })
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t`Failed to update monitor`,
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
toast({ title: t`Name is required`, variant: "destructive" })
|
||||
return
|
||||
}
|
||||
|
||||
if (isEdit && monitor) {
|
||||
const data: UpdateMonitorRequest = {
|
||||
name: name.trim(),
|
||||
url: url.trim() || undefined,
|
||||
hostname: hostname.trim() || undefined,
|
||||
port: port ? Number(port) : undefined,
|
||||
method: ["http", "https", "keyword", "json-query"].includes(type)
|
||||
? method
|
||||
: undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
body: body.trim() || undefined,
|
||||
interval,
|
||||
timeout,
|
||||
retries,
|
||||
keyword: type === "keyword" ? keyword.trim() : undefined,
|
||||
json_query: type === "json-query" ? jsonQuery.trim() : undefined,
|
||||
expected_value: type === "json-query" ? expectedValue.trim() : undefined,
|
||||
invert_keyword: type === "keyword" ? invertKeyword : undefined,
|
||||
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
|
||||
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
|
||||
description: description.trim() || undefined,
|
||||
ignore_tls_error:
|
||||
type === "https" || type === "keyword" || type === "json-query"
|
||||
? ignoreTLSError
|
||||
: undefined,
|
||||
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
|
||||
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
|
||||
}
|
||||
updateMutation.mutate({ id: monitor.id, data })
|
||||
} else {
|
||||
const data: CreateMonitorRequest = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim() || undefined,
|
||||
hostname: hostname.trim() || undefined,
|
||||
port: port ? Number(port) : undefined,
|
||||
method: ["http", "https", "keyword", "json-query"].includes(type)
|
||||
? method
|
||||
: undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
body: body.trim() || undefined,
|
||||
interval,
|
||||
timeout,
|
||||
retries,
|
||||
keyword: type === "keyword" ? keyword.trim() : undefined,
|
||||
json_query: type === "json-query" ? jsonQuery.trim() : undefined,
|
||||
expected_value: type === "json-query" ? expectedValue.trim() : undefined,
|
||||
invert_keyword: type === "keyword" ? invertKeyword : undefined,
|
||||
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
|
||||
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
|
||||
description: description.trim() || undefined,
|
||||
ignore_tls_error:
|
||||
type === "https" || type === "keyword" || type === "json-query"
|
||||
? ignoreTLSError
|
||||
: undefined,
|
||||
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
|
||||
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
|
||||
}
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
const needsUrl = ["http", "https", "keyword", "json-query"].includes(type)
|
||||
const needsHostname = ["tcp", "ping", "dns"].includes(type)
|
||||
const needsPort = type === "tcp"
|
||||
const needsHttpOptions = ["http", "https", "keyword", "json-query"].includes(type)
|
||||
const needsKeyword = type === "keyword"
|
||||
const needsJsonQuery = type === "json-query"
|
||||
const needsDnsOptions = type === "dns"
|
||||
const needsTlsOptions = type === "https"
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Configure a monitor to track website or service availability.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic">
|
||||
<Trans>Basic</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
<Trans>Advanced</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications">
|
||||
<Trans>Notifications</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
<Trans>Monitor Name</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`e.g., My Website`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">
|
||||
<Trans>Monitor Type</Trans> *
|
||||
</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as MonitorType)}
|
||||
>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONITOR_TYPES.map((mt) => (
|
||||
<SelectItem key={mt.value} value={mt.value}>
|
||||
{mt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{needsUrl && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="url">
|
||||
<Trans>URL</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder={t`https://example.com`}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsHostname && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="hostname">
|
||||
<Trans>Hostname</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
placeholder={t`example.com`}
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsPort && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="port">
|
||||
<Trans>Port</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
placeholder={t`443`}
|
||||
value={port}
|
||||
onChange={(e) =>
|
||||
setPort(
|
||||
e.target.value ? Number(e.target.value) : ""
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsHttpOptions && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="method">
|
||||
<Trans>HTTP Method</Trans>
|
||||
</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsKeyword && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="keyword">
|
||||
<Trans>Keyword to Search</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="keyword"
|
||||
placeholder={t`Success`}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
id="invertKeyword"
|
||||
checked={invertKeyword}
|
||||
onCheckedChange={setInvertKeyword}
|
||||
/>
|
||||
<Label htmlFor="invertKeyword">
|
||||
<Trans>Invert match (alert if keyword found)</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsJsonQuery && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="jsonQuery">
|
||||
<Trans>JSON Path</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="jsonQuery"
|
||||
placeholder={t`data.status`}
|
||||
value={jsonQuery}
|
||||
onChange={(e) => setJsonQuery(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expectedValue">
|
||||
<Trans>Expected Value</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="expectedValue"
|
||||
placeholder={t`active`}
|
||||
value={expectedValue}
|
||||
onChange={(e) => setExpectedValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDnsOptions && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dnsResolverMode">
|
||||
<Trans>Record Type</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
value={dnsResolverMode}
|
||||
onValueChange={setDnsResolverMode}
|
||||
>
|
||||
<SelectTrigger id="dnsResolverMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DNS_RECORD_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dnsResolveServer">
|
||||
<Trans>DNS Server (optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="dnsResolveServer"
|
||||
placeholder={t`8.8.8.8`}
|
||||
value={dnsResolveServer}
|
||||
onChange={(e) => setDnsResolveServer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
<Trans>Description</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t`Optional description for this monitor`}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="interval">
|
||||
<Trans>Interval (seconds)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="interval"
|
||||
type="number"
|
||||
min={20}
|
||||
max={86400}
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="timeout">
|
||||
<Trans>Timeout (seconds)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="retries">
|
||||
<Trans>Retries</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="retries"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={retries}
|
||||
onChange={(e) => setRetries(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsHttpOptions && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="headers">
|
||||
<Trans>Headers (JSON)</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
placeholder={`{\n "Authorization": "Bearer token"\n}`}
|
||||
value={headers}
|
||||
onChange={(e) => setHeaders(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="body">
|
||||
<Trans>Body</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
placeholder={t`Request body for POST/PUT requests`}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsTlsOptions && (
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="ignoreTLSError"
|
||||
checked={ignoreTLSError}
|
||||
onCheckedChange={setIgnoreTLSError}
|
||||
/>
|
||||
<Label htmlFor="ignoreTLSError">
|
||||
<Trans>Ignore TLS/SSL errors</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="space-y-4 mt-4">
|
||||
{needsTlsOptions && (
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="certExpiryNotification"
|
||||
checked={certExpiryNotification}
|
||||
onCheckedChange={setCertExpiryNotification}
|
||||
/>
|
||||
<Label htmlFor="certExpiryNotification">
|
||||
<Trans>Notify when certificate expires</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
{certExpiryNotification && (
|
||||
<div className="grid gap-2 mt-2">
|
||||
<Label htmlFor="certExpiryDays">
|
||||
<Trans>Days before expiry to notify</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="certExpiryDays"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={certExpiryDays}
|
||||
onChange={(e) =>
|
||||
setCertExpiryDays(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!needsTlsOptions && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Certificate expiry notifications are only available
|
||||
for HTTPS monitors.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
General notification settings will be configured in
|
||||
the Notifications tab.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Trans>Saving...</Trans>
|
||||
) : isEdit ? (
|
||||
<Trans>Update Monitor</Trans>
|
||||
) : (
|
||||
<Trans>Create Monitor</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckCircleIcon,
|
||||
Edit3Icon,
|
||||
GlobeIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
deleteMonitor,
|
||||
getMonitorTypeLabel,
|
||||
listMonitors,
|
||||
manualCheck,
|
||||
pauseMonitor,
|
||||
resumeMonitor,
|
||||
type Monitor,
|
||||
type MonitorStatus,
|
||||
formatUptime,
|
||||
formatPing,
|
||||
} from "@/lib/monitors"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AddMonitorDialog } from "./add-monitor-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
|
||||
// Status indicator component
|
||||
function StatusIndicator({ status }: { status: MonitorStatus }) {
|
||||
const colors = {
|
||||
up: "bg-green-500",
|
||||
down: "bg-red-500",
|
||||
pending: "bg-yellow-400",
|
||||
paused: "bg-gray-400",
|
||||
maintenance: "bg-blue-500",
|
||||
}
|
||||
|
||||
const icons = {
|
||||
up: CheckCircleIcon,
|
||||
down: XCircleIcon,
|
||||
pending: RefreshCwIcon,
|
||||
paused: PauseIcon,
|
||||
maintenance: RefreshCwIcon,
|
||||
}
|
||||
|
||||
const Icon = icons[status] || RefreshCwIcon
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status])} />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize text-sm">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime bar component
|
||||
function UptimeBar({ stats }: { stats?: Record<string, number> }) {
|
||||
const uptime24h = stats?.uptime_24h ?? 100
|
||||
|
||||
let color = "bg-green-500"
|
||||
if (uptime24h < 95) color = "bg-yellow-500"
|
||||
if (uptime24h < 90) color = "bg-red-500"
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all", color)}
|
||||
style={{ width: `${uptime24h}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-14">
|
||||
{formatUptime(uptime24h)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Monitor row component
|
||||
function MonitorRow({
|
||||
monitor,
|
||||
onEdit,
|
||||
}: {
|
||||
monitor: Monitor
|
||||
onEdit: (m: Monitor) => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: manualCheck,
|
||||
onSuccess: (result) => {
|
||||
toast({
|
||||
title: `Check complete`,
|
||||
description: `${monitor.name} is ${result.status} (${formatPing(result.ping)})`,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] })
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Check failed",
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: monitor.status === "paused" ? resumeMonitor : pauseMonitor,
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: monitor.status === "paused" ? "Monitor resumed" : "Monitor paused",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteMonitor,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Monitor deleted" })
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer">
|
||||
<GlobeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium hover:underline">{monitor.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{monitor.url || monitor.hostname}
|
||||
{monitor.port ? `:${monitor.port}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||
{getMonitorTypeLabel(monitor.type)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusIndicator status={monitor.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{monitor.last_check ? (
|
||||
<div className="text-sm">
|
||||
{formatPing(monitor.uptime_stats?.last_ping || 0)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeBar stats={monitor.uptime_stats} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => checkMutation.mutate(monitor.id)}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
checkMutation.isPending && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Check now</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => pauseMutation.mutate(monitor.id)}
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{monitor.status === "paused" ? (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PauseIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{monitor.status === "paused" ? "Resume" : "Pause"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Edit3Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(monitor)}>
|
||||
<Edit3Icon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => deleteMutation.mutate(monitor.id)}
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default memo(function MonitorsTable() {
|
||||
const { t } = useLingui()
|
||||
const [filter, setFilter] = useState("")
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
|
||||
|
||||
const { data: monitors = [], isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: listMonitors,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
|
||||
const filteredMonitors = useMemo(() => {
|
||||
if (!filter) return monitors
|
||||
const f = filter.toLowerCase()
|
||||
return monitors.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(f) ||
|
||||
(m.url || "").toLowerCase().includes(f) ||
|
||||
(m.hostname || "").toLowerCase().includes(f)
|
||||
)
|
||||
}, [monitors, filter])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = monitors.length
|
||||
const up = monitors.filter((m) => m.status === "up").length
|
||||
const down = monitors.filter((m) => m.status === "down").length
|
||||
const paused = monitors.filter((m) => m.status === "paused").length
|
||||
return { total, up, down, paused }
|
||||
}, [monitors])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">
|
||||
<Trans>Website & Service Monitoring</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Monitor websites, APIs, and services</Trans>
|
||||
<span className="ml-2 text-xs">
|
||||
({stats.up} <ArrowUpIcon className="inline h-3 w-3 text-green-500" />
|
||||
{stats.down > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
{stats.down}{" "}
|
||||
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
|
||||
</>
|
||||
)}
|
||||
{stats.paused > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
{stats.paused} <PauseIcon className="inline h-3 w-3 text-gray-400" />
|
||||
</>
|
||||
)}
|
||||
/ {stats.total})
|
||||
</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t`Search monitors...`}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</div>
|
||||
) : filteredMonitors.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{filter ? (
|
||||
<Trans>No monitors match your search.</Trans>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-4">
|
||||
<Trans>No monitors configured yet.</Trans>
|
||||
</p>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)} variant="outline">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add your first monitor</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Name</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Type</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Status</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Response</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Uptime (24h)</Trans>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Trans>Actions</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredMonitors.map((monitor) => (
|
||||
<MonitorRow
|
||||
key={monitor.id}
|
||||
monitor={monitor}
|
||||
onEdit={setEditingMonitor}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Add Monitor Dialog */}
|
||||
<AddMonitorDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Edit Monitor Dialog */}
|
||||
{editingMonitor && (
|
||||
<AddMonitorDialog
|
||||
open={!!editingMonitor}
|
||||
onOpenChange={(open) => !open && setEditingMonitor(null)}
|
||||
monitor={editingMonitor}
|
||||
isEdit
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
ContainerIcon,
|
||||
DatabaseBackupIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
MenuIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
import { lazy, Suspense, useState } from "react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { isAdmin, isReadOnlyUser, logOut, pb } from "@/lib/api"
|
||||
import { cn, runOnce } from "@/lib/utils"
|
||||
import { AddSystemDialog } from "./add-system"
|
||||
import { LangToggle } from "./lang-toggle"
|
||||
import { Logo } from "./logo"
|
||||
import { ModeToggle } from "./mode-toggle"
|
||||
import { $router, basePath, Link, navigate, prependBasePath } from "./router"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
|
||||
const CommandPalette = lazy(() => import("./command-palette"))
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||
|
||||
export default function Navbar() {
|
||||
const [addSystemDialogOpen, setAddSystemDialogOpen] = useState(false)
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
|
||||
const AdminLinks = AdminDropdownGroup()
|
||||
|
||||
const systemTranslation = t`System`
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
||||
<Suspense>
|
||||
<CommandPalette open={commandPaletteOpen} setOpen={setCommandPaletteOpen} />
|
||||
</Suspense>
|
||||
<AddSystemDialog open={addSystemDialogOpen} setOpen={setAddSystemDialogOpen} />
|
||||
|
||||
<Link
|
||||
href={basePath}
|
||||
aria-label="Home"
|
||||
className="p-2 ps-0 me-3 group"
|
||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||
>
|
||||
<Logo className="h-[1.2rem] md:h-5 fill-foreground" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden md:block text-sm text-muted-foreground px-4"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<SearchIcon className="me-1.5 h-4 w-4" />
|
||||
<Trans>Search</Trans>
|
||||
<span className="flex items-center ms-3.5">
|
||||
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* mobile menu */}
|
||||
<div className="ms-auto flex items-center text-xl md:hidden">
|
||||
<ModeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={() => setCommandPaletteOpen(true)}>
|
||||
<SearchIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
onMouseEnter={() => import("@/components/routes/settings/general")}
|
||||
className="ms-3"
|
||||
aria-label="Open Menu"
|
||||
>
|
||||
<MenuIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="max-w-40 truncate">{pb.authStore.record?.email}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "containers"))}
|
||||
className="flex items-center"
|
||||
>
|
||||
<ContainerIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<Trans>All Containers</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "smart"))} className="flex items-center">
|
||||
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<span>S.M.A.R.T.</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
||||
className="flex items-center"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Settings</Trans>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin() && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<UserIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Admin</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{!isReadOnlyUser() && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center"
|
||||
onSelect={() => {
|
||||
setAddSystemDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={logOut} className="flex items-center">
|
||||
<LogOutIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Log Out</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* desktop nav */}
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: ignore */}
|
||||
<div
|
||||
className="hidden md:flex items-center ms-auto"
|
||||
onMouseEnter={() => import("@/components/routes/settings/general")}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "containers")}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Containers"
|
||||
>
|
||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>All Containers</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "smart")}
|
||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="S.M.A.R.T."
|
||||
>
|
||||
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "settings", { name: "general" })}
|
||||
aria-label="Settings"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
>
|
||||
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Settings</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
|
||||
<DropdownMenuLabel>{pb.authStore.record?.email}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isAdmin() && (
|
||||
<>
|
||||
{AdminLinks}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={logOut}>
|
||||
<LogOutIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Log Out</Trans>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isReadOnlyUser() && (
|
||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Kbd = ({ children }: { children: React.ReactNode }) => (
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
|
||||
function AdminDropdownGroup() {
|
||||
return (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={prependBasePath("/_/")} target="_blank">
|
||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={prependBasePath("/_/#/collections?collection=systems")} target="_blank">
|
||||
<ServerIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Systems</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={prependBasePath("/_/#/logs")} target="_blank">
|
||||
<LogsIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Logs</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={prependBasePath("/_/#/settings/backups")} target="_blank">
|
||||
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Backups</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
createNotification,
|
||||
updateNotification,
|
||||
testNotification,
|
||||
getDefaultSettings,
|
||||
getProviderLabel,
|
||||
type Notification,
|
||||
type NotificationType,
|
||||
type CreateNotificationRequest,
|
||||
type UpdateNotificationRequest,
|
||||
} from "@/lib/notifications"
|
||||
|
||||
const providerTypes: NotificationType[] = [
|
||||
"email",
|
||||
"webhook",
|
||||
"discord",
|
||||
"slack",
|
||||
"telegram",
|
||||
"gotify",
|
||||
"pushover",
|
||||
]
|
||||
|
||||
interface NotificationSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notification?: Notification | null
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export function NotificationSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
notification,
|
||||
isEdit = false,
|
||||
}: NotificationSettingsDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedType, setSelectedType] = useState<NotificationType>(
|
||||
notification?.type || "email"
|
||||
)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createNotification,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Notification provider created successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to create notification",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateNotificationRequest }) =>
|
||||
updateNotification(id, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Notification provider updated successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to update notification",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testNotification,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Test notification sent successfully" })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to send test notification",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (data: FormData) => {
|
||||
if (isEdit && notification) {
|
||||
updateMutation.mutate({
|
||||
id: notification.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
settings: data.settings,
|
||||
is_default: data.is_default,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
name: data.name,
|
||||
type: selectedType,
|
||||
settings: data.settings,
|
||||
is_default: data.is_default,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
if (notification?.id) {
|
||||
testMutation.mutate(notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit Notification Provider" : "Add Notification Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how you want to receive alerts when monitors go down.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Provider Type</label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(v) => setSelectedType(v as NotificationType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getProviderLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderForm
|
||||
type={selectedType}
|
||||
isEdit={isEdit}
|
||||
notification={notification}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={createMutation.isPending || updateMutation.isPending}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onTest={isEdit ? handleTest : undefined}
|
||||
testPending={testMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderFormProps {
|
||||
type: NotificationType
|
||||
isEdit: boolean
|
||||
notification?: Notification | null
|
||||
onSubmit: (data: FormData) => void
|
||||
isPending: boolean
|
||||
onCancel: () => void
|
||||
onTest?: () => void
|
||||
testPending?: boolean
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
is_default: boolean
|
||||
settings: Record<string, unknown>
|
||||
}
|
||||
|
||||
function ProviderForm({
|
||||
type,
|
||||
isEdit,
|
||||
notification,
|
||||
onSubmit,
|
||||
isPending,
|
||||
onCancel,
|
||||
onTest,
|
||||
testPending,
|
||||
}: ProviderFormProps) {
|
||||
const defaultValues: FormData = {
|
||||
name: notification?.name || "",
|
||||
is_default: notification?.is_default || false,
|
||||
settings: notification?.settings || getDefaultSettings(type),
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "email":
|
||||
return (
|
||||
<EmailForm
|
||||
defaultValues={defaultValues as EmailFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "webhook":
|
||||
return (
|
||||
<WebhookForm
|
||||
defaultValues={defaultValues as WebhookFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<DiscordForm
|
||||
defaultValues={defaultValues as DiscordFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "slack":
|
||||
return (
|
||||
<SlackForm
|
||||
defaultValues={defaultValues as SlackFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "telegram":
|
||||
return (
|
||||
<TelegramForm
|
||||
defaultValues={defaultValues as TelegramFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "gotify":
|
||||
return (
|
||||
<GotifyForm
|
||||
defaultValues={defaultValues as GotifyFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
case "pushover":
|
||||
return (
|
||||
<PushoverForm
|
||||
defaultValues={defaultValues as PushoverFormData}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isPending}
|
||||
onCancel={onCancel}
|
||||
onTest={onTest}
|
||||
testPending={testPending}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Form schemas and components for each provider type
|
||||
const emailSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
is_default: z.boolean(),
|
||||
settings: z.object({
|
||||
smtp_host: z.string().min(1, "SMTP host is required"),
|
||||
smtp_port: z.coerce.number().min(1, "Port is required"),
|
||||
smtp_user: z.string(),
|
||||
smtp_password: z.string(),
|
||||
from_email: z.string().email("Invalid email"),
|
||||
to_email: z.string().email("Invalid email"),
|
||||
use_tls: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
type EmailFormData = z.infer<typeof emailSchema>
|
||||
|
||||
function EmailForm({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
isPending,
|
||||
onCancel,
|
||||
onTest,
|
||||
testPending,
|
||||
}: FormComponentProps<EmailFormData>) {
|
||||
const form = useForm<EmailFormData>({
|
||||
resolver: zodResolver(emailSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Email Notification" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.smtp_host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.smtp_port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.smtp_user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.smtp_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.from_email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="alerts@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.to_email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="admin@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.use_tls"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Use TLS</FormLabel>
|
||||
<FormDescription>Enable TLS encryption</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_default"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Default Provider</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically enable for new monitors
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{onTest && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTest}
|
||||
disabled={testPending}
|
||||
>
|
||||
{testPending ? "Sending..." : "Test"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
// Similar components for other providers... (abbreviated for brevity)
|
||||
// Webhook, Discord, Slack, Telegram, Gotify, Pushover forms
|
||||
|
||||
type FormComponentProps<T> = {
|
||||
defaultValues: T
|
||||
onSubmit: (data: T) => void
|
||||
isPending: boolean
|
||||
onCancel: () => void
|
||||
onTest?: () => void
|
||||
testPending?: boolean
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRouter } from "@nanostores/router"
|
||||
|
||||
const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
smart: "/smart",
|
||||
system: `/system/:id`,
|
||||
domain: `/domain/:id`,
|
||||
monitor: `/monitor/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
forgot_password: `/forgot-password`,
|
||||
request_otp: `/request-otp`,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The base path of the application.
|
||||
* This is used to prepend the base path to all routes.
|
||||
*/
|
||||
export const basePath = BESZEL?.BASE_PATH || ""
|
||||
|
||||
/**
|
||||
* Prepends the base path to the given path.
|
||||
* @param path The path to prepend the base path to.
|
||||
* @returns The path with the base path prepended.
|
||||
*/
|
||||
export const prependBasePath = (path: string) => (basePath + path).replaceAll("//", "/")
|
||||
|
||||
// prepend base path to routes
|
||||
for (const route in routes) {
|
||||
// @ts-expect-error need as const above to get nanostores to parse types properly
|
||||
routes[route] = prependBasePath(routes[route])
|
||||
}
|
||||
|
||||
export const $router = createRouter(routes, { links: false })
|
||||
|
||||
/** Navigate to url using router
|
||||
* Base path is automatically prepended if serving from subpath
|
||||
*/
|
||||
export const navigate = (urlString: string) => {
|
||||
$router.open(urlString)
|
||||
}
|
||||
|
||||
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
const href = props.href || ""
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
window.open(href, "_blank")
|
||||
} else {
|
||||
navigate(href)
|
||||
props.onClick?.(e)
|
||||
}
|
||||
}}
|
||||
></a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import ContainersTable from "@/components/containers-table/containers-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`All Containers`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<ContainersTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,687 @@
|
||||
import { memo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Globe,
|
||||
Calendar,
|
||||
Clock,
|
||||
Shield,
|
||||
Server,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Edit3,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Lock,
|
||||
Key,
|
||||
Fingerprint,
|
||||
FileText,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Building,
|
||||
} from "lucide-react"
|
||||
import { getDomain, getDomainHistory, refreshDomain, formatDate, formatDays } from "@/lib/domains"
|
||||
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from "recharts"
|
||||
import { Link, navigate } from "@/components/router"
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const configs = {
|
||||
active: { color: "bg-green-500", icon: CheckCircle2, text: "Active" },
|
||||
expiring: { color: "bg-yellow-500", icon: AlertTriangle, text: "Expiring Soon" },
|
||||
expired: { color: "bg-red-500", icon: XCircle, text: "Expired" },
|
||||
unknown: { color: "bg-gray-500", icon: AlertTriangle, text: "Unknown" },
|
||||
paused: { color: "bg-blue-500", icon: Clock, text: "Paused" },
|
||||
}
|
||||
|
||||
const config = configs[status as keyof typeof configs] || configs.unknown
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${config.color}`} />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize text-sm">{config.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Info card component
|
||||
function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: string; value: string; icon: any; subtitle?: string; className?: string }) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="font-semibold truncate">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
const { toast } = useToast()
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
|
||||
const { data: domain, isLoading: isDomainLoading } = useQuery({
|
||||
queryKey: ["domain", id],
|
||||
queryFn: () => getDomain(id),
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["domain-history", id],
|
||||
queryFn: () => getDomainHistory(id),
|
||||
})
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await refreshDomain(id)
|
||||
toast({ title: "Domain refresh started" })
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to refresh domain",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Are you sure you want to delete this domain?")) {
|
||||
// Delete domain logic would go here
|
||||
toast({ title: "Domain deleted" })
|
||||
navigate("/")
|
||||
}
|
||||
}
|
||||
|
||||
if (isDomainLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Globe className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Domain not found</h2>
|
||||
<p className="text-muted-foreground">The domain you are looking for does not exist.</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare chart data from history
|
||||
const chartData = history?.map((h: any) => ({
|
||||
date: new Date(h.created).toLocaleDateString(),
|
||||
daysUntilExpiry: h.days_until_expiry || 0,
|
||||
sslDaysUntil: h.ssl_days_until || 0,
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 mb-14">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{domain.favicon_url ? (
|
||||
<img src={domain.favicon_url} alt="" className="h-8 w-8" />
|
||||
) : (
|
||||
<Globe className="h-6 w-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{domain.domain_name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge status={domain.status} />
|
||||
{domain.tags?.map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`https://${domain.domain_name}`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<Trans>Visit</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<InfoCard
|
||||
title="Registrar"
|
||||
value={domain.registrar_name || "Unknown"}
|
||||
icon={Server}
|
||||
/>
|
||||
<InfoCard
|
||||
title="Domain Expiry"
|
||||
value={formatDate(domain.expiry_date)}
|
||||
subtitle={formatDays(domain.days_until_expiry)}
|
||||
icon={Calendar}
|
||||
className={domain.days_until_expiry !== undefined && domain.days_until_expiry <= 30 ? "text-yellow-600" : ""}
|
||||
/>
|
||||
<InfoCard
|
||||
title="SSL Expiry"
|
||||
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
|
||||
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
|
||||
icon={Shield}
|
||||
className={domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
|
||||
/>
|
||||
<InfoCard
|
||||
title="Location"
|
||||
value={domain.host_country || "Unknown"}
|
||||
subtitle={domain.host_isp}
|
||||
icon={MapPin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
|
||||
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
|
||||
<TabsTrigger value="overview" className="flex items-center gap-1.5 px-4">
|
||||
<Globe className="size-3.5" />
|
||||
<Trans>Overview</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dns" className="flex items-center gap-1.5 px-4">
|
||||
<Server className="size-3.5" />
|
||||
<Trans>DNS Records</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ssl" className="flex items-center gap-1.5 px-4">
|
||||
<Lock className="size-3.5" />
|
||||
<Trans>SSL Certificate</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="whois" className="flex items-center gap-1.5 px-4">
|
||||
<FileText className="size-3.5" />
|
||||
<Trans>WHOIS Info</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center gap-1.5 px-4">
|
||||
<Clock className="size-3.5" />
|
||||
<Trans>History</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="contents">
|
||||
<div className="grid gap-4">
|
||||
{/* Expiry Timeline Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Domain Expiry Timeline</CardTitle>
|
||||
<CardDescription>Days until domain and SSL expiry over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorDomain" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorSsl" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="daysUntilExpiry"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorDomain)"
|
||||
name="Domain Days"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sslDaysUntil"
|
||||
stroke="#22c55e"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorSsl)"
|
||||
name="SSL Days"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Addresses</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{domain.ipv4_addresses?.map((ip: string) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="secondary">IPv4</Badge>
|
||||
<code className="text-sm">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{domain.ipv6_addresses?.map((ip: string) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="secondary">IPv6</Badge>
|
||||
<code className="text-sm">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
|
||||
<p className="text-muted-foreground">No IP addresses found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Valuation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Purchase Price</span>
|
||||
<span className="font-medium">${domain.purchase_price || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Current Value</span>
|
||||
<span className="font-medium">${domain.current_value || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Renewal Cost</span>
|
||||
<span className="font-medium">${domain.renewal_cost || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Auto-renew</span>
|
||||
<Badge variant={domain.auto_renew ? "default" : "secondary"}>
|
||||
{domain.auto_renew ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{domain.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dns" className="contents">
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DNS Records</CardTitle>
|
||||
<CardDescription>Name servers, mail exchangers, and text records</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Nameservers */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Nameservers
|
||||
<Badge variant="secondary" className="ml-2">{domain.name_servers?.length || 0}</Badge>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{domain.name_servers?.map((ns: string, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Badge variant="outline">NS</Badge>
|
||||
<code className="text-sm">{ns}</code>
|
||||
</div>
|
||||
))}
|
||||
{!domain.name_servers?.length && (
|
||||
<p className="text-muted-foreground text-sm">No nameservers found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MX Records */}
|
||||
{domain.mx_records && domain.mx_records.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Mail Servers (MX)
|
||||
<Badge variant="secondary" className="ml-2">{domain.mx_records.length}</Badge>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{domain.mx_records?.map((mx: string, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Badge variant="outline">MX</Badge>
|
||||
<code className="text-sm">{mx}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TXT Records */}
|
||||
{domain.txt_records && domain.txt_records.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
TXT Records
|
||||
<Badge variant="secondary" className="ml-2">{domain.txt_records.length}</Badge>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{domain.txt_records?.map((txt: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Badge variant="outline">TXT</Badge>
|
||||
<code className="text-sm break-all">{txt}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DNSSEC */}
|
||||
{domain.dnssec && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">DNSSEC</h4>
|
||||
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}>
|
||||
{domain.dnssec}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ssl" className="contents">
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SSL Certificate Details</CardTitle>
|
||||
<CardDescription>Certificate information and validity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{domain.ssl_valid_to ? (
|
||||
<>
|
||||
{/* Validity */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<InfoCard
|
||||
title="Valid From"
|
||||
value={formatDate(domain.ssl_valid_from)}
|
||||
icon={Calendar}
|
||||
/>
|
||||
<InfoCard
|
||||
title="Valid Until"
|
||||
value={formatDate(domain.ssl_valid_to)}
|
||||
subtitle={formatDays(domain.ssl_days_until)}
|
||||
icon={Shield}
|
||||
className={domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14 ? "text-red-600" : ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issuer & Subject */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Issuer</p>
|
||||
<p className="font-medium">{domain.ssl_issuer || "Unknown"}</p>
|
||||
{domain.ssl_issuer_country && (
|
||||
<p className="text-sm text-muted-foreground">Country: {domain.ssl_issuer_country}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Subject</p>
|
||||
<p className="font-medium">{domain.ssl_subject || "Unknown"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="grid sm:grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">Key Size</p>
|
||||
<p className="font-medium">{domain.ssl_key_size ? `${domain.ssl_key_size} bits` : "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">Signature Algorithm</p>
|
||||
<p className="font-medium">{domain.ssl_signature_algo || "Unknown"}</p>
|
||||
</div>
|
||||
{domain.ssl_fingerprint && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm text-muted-foreground mb-1">Fingerprint</p>
|
||||
<code className="text-sm break-all">{domain.ssl_fingerprint}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Shield className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No SSL certificate information available</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="whois" className="contents">
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>WHOIS Information</CardTitle>
|
||||
<CardDescription>Domain registration details</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Registrar */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Building className="h-4 w-4" />
|
||||
Registrar
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Name</p>
|
||||
<p className="font-medium">{domain.registrar_name || "Unknown"}</p>
|
||||
</div>
|
||||
{domain.registrar_id && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">IANA ID</p>
|
||||
<p className="font-medium">{domain.registrar_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{domain.registry_domain_id && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-sm text-muted-foreground">Registry Domain ID</p>
|
||||
<p className="font-medium">{domain.registry_domain_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important Dates */}
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Important Dates
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Registration</p>
|
||||
<p className="font-medium">{formatDate(domain.creation_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Last Updated</p>
|
||||
<p className="font-medium">{formatDate(domain.updated_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Expires</p>
|
||||
<p className="font-medium">{formatDate(domain.expiry_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registrant Contact */}
|
||||
{(domain.registrant_name || domain.registrant_org) && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Registrant Contact
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{domain.registrant_name && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Name</p>
|
||||
<p className="font-medium">{domain.registrant_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{domain.registrant_org && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Organization</p>
|
||||
<p className="font-medium">{domain.registrant_org}</p>
|
||||
</div>
|
||||
)}
|
||||
{domain.registrant_country && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Country</p>
|
||||
<p className="font-medium">{domain.registrant_country}</p>
|
||||
</div>
|
||||
)}
|
||||
{(domain.registrant_city || domain.registrant_state) && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Location</p>
|
||||
<p className="font-medium">
|
||||
{[domain.registrant_city, domain.registrant_state].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abuse Contact */}
|
||||
{(domain.abuse_email || domain.abuse_phone) && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Abuse Contact
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{domain.abuse_email && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<a href={`mailto:${domain.abuse_email}`} className="font-medium text-primary hover:underline">
|
||||
{domain.abuse_email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{domain.abuse_phone && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Phone</p>
|
||||
<p className="font-medium">{domain.abuse_phone}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain Status */}
|
||||
{domain.status && domain.status !== "Unknown" && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Domain Status
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{domain.status.split(", ").map((status: string, i: number) => (
|
||||
<Badge key={i} variant="secondary">{status}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="contents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Change History</CardTitle>
|
||||
<CardDescription>Historical changes to domain information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{history?.map((item: any) => (
|
||||
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.change_type}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.change_description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(item.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!history?.length && (
|
||||
<p className="text-muted-foreground text-center py-8">No history available</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||
import SystemsTable from "@/components/systems-table/systems-table"
|
||||
import MonitorsTable from "@/components/monitors-table/monitors-table"
|
||||
import DomainsTable from "@/components/domains-table/domains-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`Dashboard`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Section 1: Device Monitoring (Primary) */}
|
||||
<section>
|
||||
<ActiveAlerts />
|
||||
<Suspense>
|
||||
<SystemsTable />
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
{/* Section 2: Website & Service Monitoring (Secondary) */}
|
||||
<section>
|
||||
<Suspense>
|
||||
<MonitorsTable />
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
{/* Section 3: Domain Expiry Monitoring */}
|
||||
<section>
|
||||
<Suspense>
|
||||
<DomainsTable />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,520 @@
|
||||
import { memo, useState, useMemo } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Globe,
|
||||
Clock,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Edit3,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
getMonitor,
|
||||
getMonitorStats,
|
||||
getMonitorHeartbeats,
|
||||
manualCheck,
|
||||
pauseMonitor,
|
||||
resumeMonitor,
|
||||
deleteMonitor,
|
||||
getMonitorTypeLabel,
|
||||
formatUptime,
|
||||
formatPing,
|
||||
} from "@/lib/monitors"
|
||||
import { formatDate } from "@/lib/domains"
|
||||
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, AreaChart, Area } from "recharts"
|
||||
import { Link, navigate } from "@/components/router"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const configs = {
|
||||
up: { color: "bg-green-500", icon: CheckCircle2, text: "Up" },
|
||||
down: { color: "bg-red-500", icon: XCircle, text: "Down" },
|
||||
pending: { color: "bg-yellow-500", icon: Clock, text: "Pending" },
|
||||
paused: { color: "bg-gray-500", icon: PauseIcon, text: "Paused" },
|
||||
maintenance: { color: "bg-blue-500", icon: Activity, text: "Maintenance" },
|
||||
}
|
||||
|
||||
const config = configs[status as keyof typeof configs] || configs.pending
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2.5 w-2.5 rounded-full ${config.color}`} />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize text-sm">{config.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stat card component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
subtitle,
|
||||
trend,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
icon: any
|
||||
subtitle?: string
|
||||
trend?: "up" | "down" | "neutral"
|
||||
className?: string
|
||||
}) {
|
||||
const TrendIcon = trend === "up" ? TrendingUp : trend === "down" ? TrendingDown : null
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold truncate">{value}</p>
|
||||
{TrendIcon && <TrendIcon className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
|
||||
const { data: monitor, isLoading: isMonitorLoading } = useQuery({
|
||||
queryKey: ["monitor", id],
|
||||
queryFn: () => getMonitor(id),
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ["monitor-stats", id],
|
||||
queryFn: () => getMonitorStats(id),
|
||||
})
|
||||
|
||||
const { data: heartbeatsData } = useQuery({
|
||||
queryKey: ["monitor-heartbeats", id],
|
||||
queryFn: () => getMonitorHeartbeats(id),
|
||||
})
|
||||
const heartbeats = heartbeatsData?.heartbeats
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => manualCheck(id),
|
||||
onSuccess: (result) => {
|
||||
toast({
|
||||
title: `Check complete`,
|
||||
description: `${monitor?.name} is ${result.status}`,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
|
||||
queryClient.invalidateQueries({ queryKey: ["monitor-heartbeats", id] })
|
||||
},
|
||||
})
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: () => (monitor?.status === "paused" ? resumeMonitor(id) : pauseMonitor(id)),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: monitor?.status === "paused" ? "Monitor resumed" : "Monitor paused",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["monitor", id] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteMonitor(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Monitor deleted" })
|
||||
navigate("/")
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm("Are you sure you want to delete this monitor?")) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare chart data from heartbeats
|
||||
const chartData = useMemo(() => {
|
||||
if (!heartbeats) return []
|
||||
return heartbeats
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h: any) => ({
|
||||
time: new Date(h.timestamp).toLocaleTimeString(),
|
||||
responseTime: h.ping || 0,
|
||||
status: h.status === "up" ? 1 : 0,
|
||||
}))
|
||||
}, [heartbeats])
|
||||
|
||||
// Calculate stats
|
||||
const uptimeStats = useMemo(() => {
|
||||
if (!heartbeats || !Array.isArray(heartbeats) || heartbeats.length === 0) return null
|
||||
const total = heartbeats.length
|
||||
const up = heartbeats.filter((h: any) => h.status === "up").length
|
||||
const avgResponse = heartbeats.reduce((sum: number, h: any) => sum + (h.ping || 0), 0) / total
|
||||
return {
|
||||
uptime: ((up / total) * 100).toFixed(2),
|
||||
avgResponse: avgResponse.toFixed(0),
|
||||
totalChecks: total,
|
||||
}
|
||||
}, [heartbeats])
|
||||
|
||||
if (isMonitorLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Globe className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Monitor not found</h2>
|
||||
<p className="text-muted-foreground">The monitor you are looking for does not exist.</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isUp = monitor.status === "up"
|
||||
const isPaused = monitor.status === "paused"
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 mb-14">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-full flex items-center justify-center",
|
||||
isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : "bg-red-500/10"
|
||||
)}
|
||||
>
|
||||
<Globe
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{monitor.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<StatusBadge status={monitor.status} />
|
||||
<Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge>
|
||||
{monitor.interval && (
|
||||
<Badge variant="outline">{monitor.interval}s interval</Badge>
|
||||
)}
|
||||
</div>
|
||||
{monitor.url && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending || isPaused}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
|
||||
<Trans>Check Now</Trans>
|
||||
</Button>
|
||||
{monitor.url && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={monitor.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<Trans>Visit</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pauseMutation.mutate()}
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{monitor.status === "paused" ? (
|
||||
<>
|
||||
<PlayIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Resume</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Pause</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Uptime (24h)"
|
||||
value={formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)}
|
||||
icon={Activity}
|
||||
trend={stats?.uptime_24h && (stats.uptime_24h.up / stats.uptime_24h.total) * 100 >= 99 ? "up" : "down"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Uptime (7d)"
|
||||
value={formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}
|
||||
icon={Activity}
|
||||
trend={stats?.uptime_7d && (stats.uptime_7d.up / stats.uptime_7d.total) * 100 >= 99 ? "up" : "down"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Uptime (30d)"
|
||||
value={formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}
|
||||
icon={Activity}
|
||||
trend={stats?.uptime_30d && (stats.uptime_30d.up / stats.uptime_30d.total) * 100 >= 99 ? "up" : "down"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Response Time"
|
||||
value={uptimeStats ? `${uptimeStats.avgResponse}ms` : "-"}
|
||||
subtitle={`${uptimeStats?.totalChecks || 0} checks`}
|
||||
icon={Clock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
|
||||
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
|
||||
<TabsTrigger value="overview" className="w-full flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
<Trans>Overview</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="response" className="w-full flex items-center gap-1.5">
|
||||
<TrendingUp className="size-3.5" />
|
||||
<Trans>Response Times</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="w-full flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
<Trans>Check History</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="contents">
|
||||
<div className="grid gap-4">
|
||||
{/* Response Time Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Response Time History</CardTitle>
|
||||
<CardDescription>Response times for the last 50 checks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} unit="ms" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="responseTime"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorResponse)"
|
||||
name="Response Time (ms)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monitor Details */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monitor Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<span className="font-medium">{getMonitorTypeLabel(monitor.type)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Interval</span>
|
||||
<span className="font-medium">{monitor.interval}s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retries</span>
|
||||
<span className="font-medium">{monitor.retries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span className="font-medium">{formatDate(monitor.created)}</span>
|
||||
</div>
|
||||
{monitor.last_check && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Last Check</span>
|
||||
<span className="font-medium">{formatDate(monitor.last_check)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uptime Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">24 Hours</span>
|
||||
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">7 Days</span>
|
||||
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">30 Days</span>
|
||||
<span className="font-medium text-green-600">{formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}</span>
|
||||
</div>
|
||||
{uptimeStats && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Checks</span>
|
||||
<span className="font-medium">{uptimeStats.totalChecks}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="response" className="contents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Response Time Analysis</CardTitle>
|
||||
<CardDescription>Detailed response time metrics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorResponseDetail" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} unit="ms" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="responseTime"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorResponseDetail)"
|
||||
name="Response Time (ms)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="contents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Checks</CardTitle>
|
||||
<CardDescription>Last 50 monitor checks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Response Time</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{heartbeats?.slice(0, 50).map((hb: any) => (
|
||||
<TableRow key={hb.id}>
|
||||
<TableCell>{formatDate(hb.timestamp)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hb.status === "up" ? "default" : "destructive"}>
|
||||
{hb.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatPing(hb.ping)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{hb.message || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!heartbeats?.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
No check history available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
DownloadIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { alertInfo } from "@/lib/alerts"
|
||||
import { pb } from "@/lib/api"
|
||||
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
|
||||
import type { AlertsHistoryRecord } from "@/types"
|
||||
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||
|
||||
const SectionIntro = memo(() => {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>Alert History</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>View your 200 most recent alerts.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default function AlertsHistoryDataTable() {
|
||||
const [data, setData] = useState<AlertsHistoryRecord[]>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
const { toast } = useToast()
|
||||
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Store pagination preference in local storage
|
||||
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = {
|
||||
expand: "system",
|
||||
fields: "id,name,value,state,created,resolved,expand.system.name",
|
||||
}
|
||||
// Initial load
|
||||
pb.collection<AlertsHistoryRecord>("alerts_history")
|
||||
.getList(0, 200, {
|
||||
...pbOptions,
|
||||
sort: "-created",
|
||||
})
|
||||
.then(({ items }) => setData(items))
|
||||
|
||||
// Subscribe to changes
|
||||
;(async () => {
|
||||
unsubscribe = await pb.collection("alerts_history").subscribe(
|
||||
"*",
|
||||
(e) => {
|
||||
if (e.action === "create") {
|
||||
setData((current) => [e.record as AlertsHistoryRecord, ...current])
|
||||
}
|
||||
if (e.action === "update") {
|
||||
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
|
||||
}
|
||||
if (e.action === "delete") {
|
||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||
}
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
})()
|
||||
// Unsubscribe on unmount
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
className="ms-2"
|
||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
...alertsHistoryColumns,
|
||||
],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const system = row.original.expand?.system?.name ?? ""
|
||||
const name = row.getValue("name") ?? ""
|
||||
const created = row.getValue("created") ?? ""
|
||||
const search = String(filterValue).toLowerCase()
|
||||
return (
|
||||
system.toLowerCase().includes(search) ||
|
||||
(name as string).toLowerCase().includes(search) ||
|
||||
(created as string).toLowerCase().includes(search)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// Bulk delete handler
|
||||
const handleBulkDelete = async () => {
|
||||
setDeleteDialogOpen(false)
|
||||
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
|
||||
try {
|
||||
let batch = pb.createBatch()
|
||||
let inBatch = 0
|
||||
for (const id of selectedIds) {
|
||||
batch.collection("alerts_history").delete(id)
|
||||
inBatch++
|
||||
if (inBatch > 20) {
|
||||
await batch.send()
|
||||
batch = pb.createBatch()
|
||||
inBatch = 0
|
||||
}
|
||||
}
|
||||
inBatch && (await batch.send())
|
||||
table.resetRowSelection()
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: `Failed to delete records.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export to CSV handler
|
||||
const handleExportCSV = () => {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
if (!selectedRows.length) return
|
||||
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
|
||||
system: (record) => record.expand?.system?.name || record.system,
|
||||
name: (record) => alertInfo[record.name]?.name() || record.name,
|
||||
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
|
||||
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
|
||||
created: (record) => formatShortDate(record.created),
|
||||
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
|
||||
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
|
||||
}
|
||||
const csvRows = [Object.keys(cells).join(",")]
|
||||
for (const row of selectedRows) {
|
||||
const r = row.original
|
||||
csvRows.push(
|
||||
Object.values(cells)
|
||||
.map((val) => val(r))
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "alerts_history.csv"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
<div className="@3xl:flex items-end mb-4 gap-4">
|
||||
<SectionIntro />
|
||||
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
|
||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="h-9 shrink-0">
|
||||
<Trash2Icon className="size-4 shrink-0" />
|
||||
<span className="ms-1">
|
||||
<Trans>Delete</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Trans>This will permanently delete all selected records from the database.</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<Trans>Continue</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Export</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="px-4 w-full max-w-full @3xl:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-border/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||
<Trans>No results.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between ps-1 tabular-nums">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
<Trans>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
<Trans>Rows per page</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-18" id="rows-per-page">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 50, 100, 200].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
<Trans>
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="ms-auto flex items-center gap-2 lg:ms-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-9 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-9"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-9"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-9 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { redirectPage } from "@nanostores/router"
|
||||
import clsx from "clsx"
|
||||
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { $router } from "@/components/router"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { isAdmin, pb } from "@/lib/api"
|
||||
|
||||
export default function ConfigYaml() {
|
||||
const [configContent, setConfigContent] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
|
||||
setConfigContent(config)
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin()) {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>YAML Configuration</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Export your current systems configuration.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed my-1">
|
||||
<Trans>
|
||||
Systems may be managed in a <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file
|
||||
inside your data directory.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
On each restart, systems in the database will be updated to match the systems defined in the file.
|
||||
</Trans>
|
||||
</p>
|
||||
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
||||
<AlertCircleIcon className="size-4.5 stroke-destructive" />
|
||||
<AlertTitle>
|
||||
<Trans>Caution - potential data loss</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
|
||||
</Trans>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
{configContent && (
|
||||
<Textarea
|
||||
dir="ltr"
|
||||
autoFocus
|
||||
defaultValue={configContent}
|
||||
spellCheck="false"
|
||||
rows={Math.min(25, configContent.split("\n").length)}
|
||||
className="font-mono whitespace-pre"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-5" />
|
||||
<Button type="button" className="mt-2 flex items-center gap-1" onClick={fetchConfig} disabled={isLoading}>
|
||||
<ButtonIcon className={clsx("h-4 w-4 me-0.5", isLoading && "animate-spin")} />
|
||||
<Trans>Export configuration</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/** biome-ignore-all lint/correctness/useUniqueElementIds: component is only rendered once */
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Slider from "@/components/ui/slider"
|
||||
import { HourFormat, Unit } from "@/lib/enums"
|
||||
import { dynamicActivate } from "@/lib/i18n"
|
||||
import languages from "@/lib/languages"
|
||||
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
|
||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
|
||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { i18n } = useLingui()
|
||||
const currentUserSettings = useStore($userSettings)
|
||||
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
const formData = new FormData(e.target as HTMLFormElement)
|
||||
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||
await saveSettings(data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>General</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Change general application options.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
||||
<LanguagesIcon className="h-4 w-4" />
|
||||
<Trans>Language</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Want to help improve our translations? Check{" "}
|
||||
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
||||
Crowdin
|
||||
</a>{" "}
|
||||
for details.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Label className="block" htmlFor="lang">
|
||||
<Trans>Preferred Language</Trans>
|
||||
</Label>
|
||||
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
||||
<SelectTrigger id="lang">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map(([lang, label, e]) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
<span className="me-2.5">
|
||||
{e || (
|
||||
<code
|
||||
aria-hidden="true"
|
||||
className="font-mono bg-muted text-[.65em] w-5 h-4 inline-grid place-items-center"
|
||||
>
|
||||
{lang}
|
||||
</code>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Layout width</Trans>
|
||||
</h3>
|
||||
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
|
||||
</Label>
|
||||
</div>
|
||||
<Slider
|
||||
id="layoutWidth"
|
||||
name="layoutWidth"
|
||||
value={[layoutWidth]}
|
||||
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
|
||||
min={1000}
|
||||
max={2000}
|
||||
step={10}
|
||||
className="w-full mb-1"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Chart options</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Adjust display options for charts.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="block" htmlFor="chartTime">
|
||||
<Trans>Default time period</Trans>
|
||||
</Label>
|
||||
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
|
||||
<SelectTrigger id="chartTime">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="block" htmlFor="hourFormat">
|
||||
<Trans>Time format</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="hourFormat"
|
||||
key={userSettings.hourFormat}
|
||||
defaultValue={userSettings.hourFormat ?? (currentHour12() ? HourFormat["12h"] : HourFormat["24h"])}
|
||||
>
|
||||
<SelectTrigger id="hourFormat">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(HourFormat).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans comment="Temperature / network units">Unit preferences</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Change display units for metrics.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="block" htmlFor="unitTemp">
|
||||
<Trans>Temperature unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitTemp"
|
||||
key={userSettings.unitTemp}
|
||||
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
|
||||
>
|
||||
<SelectTrigger id="unitTemp">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(Unit.Celsius)}>
|
||||
<Trans>Celsius (°C)</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={String(Unit.Fahrenheit)}>
|
||||
<Trans>Fahrenheit (°F)</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="block" htmlFor="unitNet">
|
||||
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitNet"
|
||||
key={userSettings.unitNet}
|
||||
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
|
||||
>
|
||||
<SelectTrigger id="unitNet">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(Unit.Bytes)}>
|
||||
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={String(Unit.Bits)}>
|
||||
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="block" htmlFor="unitDisk">
|
||||
<Trans>Disk unit</Trans>
|
||||
</Label>
|
||||
<Select
|
||||
name="unitDisk"
|
||||
key={userSettings.unitDisk}
|
||||
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
|
||||
>
|
||||
<SelectTrigger id="unitDisk">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(Unit.Bytes)}>
|
||||
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={String(Unit.Bits)}>
|
||||
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Warning thresholds</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Set percentage thresholds for meter colors.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-end">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="colorWarn">
|
||||
<Trans>Warning (%)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="colorWarn"
|
||||
name="colorWarn"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="min-w-24"
|
||||
defaultValue={userSettings.colorWarn ?? 65}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="colorCrit">
|
||||
<Trans>Critical (%)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="colorCrit"
|
||||
name="colorCrit"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="min-w-24"
|
||||
defaultValue={userSettings.colorCrit ?? 90}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||
<Trans>Save Settings</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { redirectPage } from "@nanostores/router"
|
||||
import { LoaderCircleIcon, SendIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { $router } from "@/components/router"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { isAdmin, pb } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface HeartbeatStatus {
|
||||
enabled: boolean
|
||||
url?: string
|
||||
interval?: number
|
||||
method?: string
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export default function HeartbeatSettings() {
|
||||
const [status, setStatus] = useState<HeartbeatStatus | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
if (!isAdmin()) {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await pb.send<HeartbeatStatus>("/api/beszel/heartbeat-status", {})
|
||||
setStatus(res)
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: (error as Error).message,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestHeartbeat() {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const res = await pb.send<{ err: string | false }>("/api/beszel/test-heartbeat", {
|
||||
method: "POST",
|
||||
})
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Heartbeat sent successfully`,
|
||||
description: t`Check your monitoring service`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: (res.err as string) ?? t`Failed to send heartbeat`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: (error as Error).message,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>Heartbeat Monitoring</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it
|
||||
to the internet.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{status?.enabled ? (
|
||||
<EnabledState status={status} isTesting={isTesting} sendTestHeartbeat={sendTestHeartbeat} />
|
||||
) : (
|
||||
<NotEnabledState isLoading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnabledState({
|
||||
status,
|
||||
isTesting,
|
||||
sendTestHeartbeat,
|
||||
}: {
|
||||
status: HeartbeatStatus
|
||||
isTesting: boolean
|
||||
sendTestHeartbeat: () => void
|
||||
}) {
|
||||
const TestIcon = isTesting ? LoaderCircleIcon : SendIcon
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ConfigItem label={t`Endpoint URL`} value={status.url ?? ""} mono />
|
||||
<ConfigItem label={t`Interval`} value={`${status.interval}s`} />
|
||||
<ConfigItem label={t`HTTP Method`} value={status.method ?? "POST"} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-medium mb-1">
|
||||
<Trans>Test heartbeat</Trans>
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
|
||||
<Trans>Send a single heartbeat ping to verify your endpoint is working.</Trans>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={sendTestHeartbeat}
|
||||
disabled={isTesting}
|
||||
>
|
||||
<TestIcon className={cn("size-4", isTesting && "animate-spin")} />
|
||||
<Trans>Send test heartbeat</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-medium mb-2">
|
||||
<Trans>Payload format</Trans>
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-2">
|
||||
<Trans>
|
||||
When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems,
|
||||
and triggered alerts.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
The overall status is <code className="bg-muted rounded-sm px-1 text-primary">ok</code> when all systems are
|
||||
up, <code className="bg-muted rounded-sm px-1 text-primary">warn</code> when alerts are triggered, and{" "}
|
||||
<code className="bg-muted rounded-sm px-1 text-primary">error</code> when any system is down.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotEnabledState({ isLoading }: { isLoading?: boolean }) {
|
||||
return (
|
||||
<div className={cn("grid gap-4", isLoading && "animate-pulse")}>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-3">
|
||||
<Trans>Set the following environment variables on your Beszel hub to enable heartbeat monitoring:</Trans>
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<EnvVarItem
|
||||
name="HEARTBEAT_URL"
|
||||
description={t`Endpoint URL to ping (required)`}
|
||||
example="https://uptime.betterstack.com/api/v1/heartbeat/xxxx"
|
||||
/>
|
||||
<EnvVarItem name="HEARTBEAT_INTERVAL" description={t`Seconds between pings (default: 60)`} example="60" />
|
||||
<EnvVarItem
|
||||
name="HEARTBEAT_METHOD"
|
||||
description={t`HTTP method: POST, GET, or HEAD (default: POST)`}
|
||||
example="POST"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>After setting the environment variables, restart your Beszel hub for changes to take effect.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-0.5">{label}</p>
|
||||
<p className={cn("text-sm text-muted-foreground break-all", mono && "font-mono")}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvVarItem({ name, description, example }: { name: string; description: string; example: string }) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-md px-3 py-2.5 grid gap-1.5">
|
||||
<code className="text-sm font-mono text-primary font-medium leading-tight">{name}</code>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Example:</Trans> <code className="font-mono">{example}</code>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath, redirectPage } from "@nanostores/router"
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
BellIcon,
|
||||
FileSlidersIcon,
|
||||
FingerprintIcon,
|
||||
HeartPulseIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react"
|
||||
import { lazy, useEffect } from "react"
|
||||
import { $router } from "@/components/router.tsx"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||
import { toast } from "@/components/ui/use-toast.ts"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $userSettings } from "@/lib/stores.ts"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { Separator } from "../../ui/separator"
|
||||
import { SidebarNav } from "./sidebar-nav.tsx"
|
||||
|
||||
const generalSettingsImport = () => import("./general.tsx")
|
||||
const notificationsSettingsImport = () => import("./notifications.tsx")
|
||||
const configYamlSettingsImport = () => import("./config-yaml.tsx")
|
||||
const fingerprintsSettingsImport = () => import("./tokens-fingerprints.tsx")
|
||||
const alertsHistoryDataTableSettingsImport = () => import("./alerts-history-data-table.tsx")
|
||||
const heartbeatSettingsImport = () => import("./heartbeat.tsx")
|
||||
|
||||
const GeneralSettings = lazy(generalSettingsImport)
|
||||
const NotificationsSettings = lazy(notificationsSettingsImport)
|
||||
const ConfigYamlSettings = lazy(configYamlSettingsImport)
|
||||
const FingerprintsSettings = lazy(fingerprintsSettingsImport)
|
||||
const AlertsHistoryDataTableSettings = lazy(alertsHistoryDataTableSettingsImport)
|
||||
const HeartbeatSettings = lazy(heartbeatSettingsImport)
|
||||
|
||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
try {
|
||||
// get fresh copy of settings
|
||||
const req = await pb.collection("user_settings").getFirstListItem("", {
|
||||
fields: "id,settings",
|
||||
})
|
||||
// update user settings
|
||||
const updatedSettings = await pb.collection("user_settings").update(req.id, {
|
||||
settings: {
|
||||
...req.settings,
|
||||
...newSettings,
|
||||
},
|
||||
})
|
||||
$userSettings.set(updatedSettings.settings)
|
||||
toast({
|
||||
title: t`Settings saved`,
|
||||
description: t`Your user settings have been updated.`,
|
||||
})
|
||||
} catch (e) {
|
||||
// console.error('update settings', e)
|
||||
toast({
|
||||
title: t`Failed to save settings`,
|
||||
description: t`Check logs for more details.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useLingui()
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: t({ message: `General`, comment: "Context: General settings" }),
|
||||
href: getPagePath($router, "settings", { name: "general" }),
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
title: t`Notifications`,
|
||||
href: getPagePath($router, "settings", { name: "notifications" }),
|
||||
icon: BellIcon,
|
||||
preload: notificationsSettingsImport,
|
||||
},
|
||||
{
|
||||
title: t`Tokens & Fingerprints`,
|
||||
href: getPagePath($router, "settings", { name: "tokens" }),
|
||||
icon: FingerprintIcon,
|
||||
noReadOnly: true,
|
||||
preload: fingerprintsSettingsImport,
|
||||
},
|
||||
{
|
||||
title: t`Alert History`,
|
||||
href: getPagePath($router, "settings", { name: "alert-history" }),
|
||||
icon: AlertOctagonIcon,
|
||||
preload: alertsHistoryDataTableSettingsImport,
|
||||
},
|
||||
{
|
||||
title: t`Heartbeat`,
|
||||
href: getPagePath($router, "settings", { name: "heartbeat" }),
|
||||
icon: HeartPulseIcon,
|
||||
admin: true,
|
||||
preload: heartbeatSettingsImport,
|
||||
},
|
||||
{
|
||||
title: t`YAML Config`,
|
||||
href: getPagePath($router, "settings", { name: "config" }),
|
||||
icon: FileSlidersIcon,
|
||||
admin: true,
|
||||
preload: configYamlSettingsImport,
|
||||
},
|
||||
]
|
||||
|
||||
const page = useStore($router)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: no dependencies
|
||||
useEffect(() => {
|
||||
document.title = `${t`Settings`} / Beszel`
|
||||
// @ts-expect-error redirect to account page if no page is specified
|
||||
if (!page?.params?.name) {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="pt-5 px-4 pb-8 min-h-96 mb-14 sm:pt-6 sm:px-7">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="mb-1">
|
||||
<Trans>Settings</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Manage display and notification preferences.</Trans>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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-12">
|
||||
<aside className="md:max-w-52 min-w-40">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* @ts-ignore */}
|
||||
<SettingsContent name={page?.params?.name ?? "general"} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsContent({ name }: { name: string }) {
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
switch (name) {
|
||||
case "general":
|
||||
return <GeneralSettings userSettings={userSettings} />
|
||||
case "notifications":
|
||||
return <NotificationsSettings userSettings={userSettings} />
|
||||
case "config":
|
||||
return <ConfigYamlSettings />
|
||||
case "tokens":
|
||||
return <FingerprintsSettings />
|
||||
case "alert-history":
|
||||
return <AlertsHistoryDataTableSettings />
|
||||
case "heartbeat":
|
||||
return <HeartbeatSettings />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
||||
import { type ChangeEventHandler, useEffect, useState } from "react"
|
||||
import * as v from "valibot"
|
||||
import { prependBasePath } from "@/components/router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { InputTags } from "@/components/ui/input-tags"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { isAdmin, pb } from "@/lib/api"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import { QuietHours } from "./quiet-hours"
|
||||
import type { ClientResponseError } from "pocketbase"
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
onUrlChange: ChangeEventHandler<HTMLInputElement>
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const NotificationSchema = v.object({
|
||||
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
|
||||
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||
})
|
||||
|
||||
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
|
||||
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
|
||||
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// update values when userSettings changes
|
||||
useEffect(() => {
|
||||
setWebhooks(userSettings.webhooks ?? [])
|
||||
setEmails(userSettings.emails ?? [])
|
||||
}, [userSettings])
|
||||
|
||||
function addWebhook() {
|
||||
setWebhooks([...webhooks, ""])
|
||||
// focus on the new input
|
||||
queueMicrotask(() => {
|
||||
const inputs = document.querySelectorAll("#webhooks input") as NodeListOf<HTMLInputElement>
|
||||
inputs[inputs.length - 1]?.focus()
|
||||
})
|
||||
}
|
||||
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
|
||||
|
||||
function updateWebhook(index: number, value: string) {
|
||||
const newWebhooks = [...webhooks]
|
||||
newWebhooks[index] = value
|
||||
setWebhooks(newWebhooks)
|
||||
}
|
||||
|
||||
async function updateSettings() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||
await saveSettings(parsedData)
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: t`Failed to save settings`,
|
||||
description: (e as Error).message,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">
|
||||
<Trans>Notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Configure how you receive alert notifications.</Trans>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
||||
<Trans>
|
||||
Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
|
||||
the systems table.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-2">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Email notifications</Trans>
|
||||
</h3>
|
||||
{isAdmin() && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Please{" "}
|
||||
<a href={prependBasePath("/_/#/settings/mail")} className="link" target="_blank">
|
||||
configure an SMTP server
|
||||
</a>{" "}
|
||||
to ensure alerts are delivered.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Label className="block" htmlFor="email">
|
||||
<Trans>To email(s)</Trans>
|
||||
</Label>
|
||||
<InputTags
|
||||
value={emails}
|
||||
onChange={setEmails}
|
||||
placeholder={t`Enter email address...`}
|
||||
className="w-full"
|
||||
type="email"
|
||||
id="email"
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
<Trans>Save address using enter key or comma. Leave blank to disable email notifications.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add URL</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{webhooks.length > 0 && (
|
||||
<div className="grid gap-2.5" id="webhooks">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<ShoutrrrUrlCard
|
||||
key={index}
|
||||
url={webhook}
|
||||
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
|
||||
onRemove={() => removeWebhook(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<QuietHours />
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 disabled:opacity-100"
|
||||
onClick={updateSettings}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||
<Trans>Save Settings</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function showTestNotificationError(msg: string) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: msg ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const sendTestNotification = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
showTestNotificationError(res.err)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showTestNotificationError((e as ClientResponseError).data?.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-table-header p-2 md:p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="url"
|
||||
className="light:bg-card"
|
||||
required
|
||||
placeholder="generic://webhook.site/xxxxxx"
|
||||
value={url}
|
||||
onChange={onUrlChange}
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span>
|
||||
<Trans>
|
||||
Test <span className="hidden sm:inline">URL</span>
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsNotificationsPage
|
||||
@@ -0,0 +1,535 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
ActivityIcon,
|
||||
PenSquareIcon,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { formatShortDate } from "@/lib/utils"
|
||||
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
||||
|
||||
const quietHoursTranslation = t`Quiet Hours`
|
||||
|
||||
export function QuietHours() {
|
||||
const [data, setData] = useState<QuietHoursRecord[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
||||
const { toast } = useToast()
|
||||
const systems = useStore($systems)
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = {
|
||||
expand: "system",
|
||||
fields: "id,user,system,type,start,end,expand.system.name",
|
||||
}
|
||||
// Initial load
|
||||
pb.collection<QuietHoursRecord>("quiet_hours")
|
||||
.getList(0, 200, {
|
||||
...pbOptions,
|
||||
sort: "system",
|
||||
})
|
||||
.then(({ items }) => setData(items))
|
||||
|
||||
// Subscribe to changes
|
||||
;(async () => {
|
||||
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||
"*",
|
||||
(e) => {
|
||||
if (e.action === "create") {
|
||||
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||
}
|
||||
if (e.action === "update") {
|
||||
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
||||
}
|
||||
if (e.action === "delete") {
|
||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||
}
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
})()
|
||||
// Unsubscribe on unmount
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await pb.collection("quiet_hours").delete(id)
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: (e as Error).message || "Failed to delete quiet hours.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (record: QuietHoursRecord) => {
|
||||
setEditingRecord(record)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingRecord(null)
|
||||
}
|
||||
|
||||
const formatDateTime = (record: QuietHoursRecord) => {
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, show only time
|
||||
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
return `${startTime} - ${endTime}`
|
||||
}
|
||||
// For one-time windows, show full date and time
|
||||
const start = formatShortDate(record.start)
|
||||
const end = formatShortDate(record.end)
|
||||
return `${start} - ${end}`
|
||||
}
|
||||
|
||||
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
|
||||
const now = new Date()
|
||||
|
||||
if (record.type === "daily") {
|
||||
// For daily windows, check if current time is within the window
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
// Get current time in local timezone
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
||||
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
||||
|
||||
// Convert UTC to local time using the stored date's offset, not the current date's offset
|
||||
// This avoids DST mismatch when records were saved in a different DST period
|
||||
const localStartMinutes = (startMinutes - startDate.getTimezoneOffset() + 1440) % 1440
|
||||
const localEndMinutes = (endMinutes - endDate.getTimezoneOffset() + 1440) % 1440
|
||||
|
||||
// Handle cases where window spans midnight
|
||||
if (localStartMinutes <= localEndMinutes) {
|
||||
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||
} else {
|
||||
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||
}
|
||||
} else {
|
||||
// For one-time windows
|
||||
const startDate = new Date(record.start)
|
||||
const endDate = new Date(record.end)
|
||||
|
||||
if (now >= startDate && now < endDate) {
|
||||
return "active"
|
||||
} else if (now >= endDate) {
|
||||
return "past"
|
||||
} else {
|
||||
return "inactive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h3 className="mb-1 text-lg font-medium">{quietHoursTranslation}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
||||
</Dialog>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<Trans>System</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4" />
|
||||
<Trans>Type</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4" />
|
||||
<Trans>Schedule</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
<Trans>State</Trans>
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4 text-right sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
{(() => {
|
||||
const state = getWindowState(record)
|
||||
const stateConfig = {
|
||||
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
|
||||
}
|
||||
const config = stateConfig[state]
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
||||
<PenSquareIcon className="me-2.5 size-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
||||
function formatDateTimeLocal(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
const hours = String(date.getHours()).padStart(2, "0")
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function QuietHoursDialog({
|
||||
editingRecord,
|
||||
systems,
|
||||
onClose,
|
||||
toast,
|
||||
}: {
|
||||
editingRecord: QuietHoursRecord | null
|
||||
systems: SystemRecord[]
|
||||
onClose: () => void
|
||||
toast: ReturnType<typeof useToast>["toast"]
|
||||
}) {
|
||||
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
||||
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
||||
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
|
||||
const [startDateTime, setStartDateTime] = useState("")
|
||||
const [endDateTime, setEndDateTime] = useState("")
|
||||
const [startTime, setStartTime] = useState("")
|
||||
const [endTime, setEndTime] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRecord) {
|
||||
setSelectedSystem(editingRecord.system || "")
|
||||
setIsGlobal(!editingRecord.system)
|
||||
setWindowType(editingRecord.type)
|
||||
if (editingRecord.type === "daily") {
|
||||
// Extract time from datetime
|
||||
const start = new Date(editingRecord.start)
|
||||
const end = editingRecord.end ? new Date(editingRecord.end) : null
|
||||
setStartTime(start.toTimeString().slice(0, 5))
|
||||
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
|
||||
} else {
|
||||
// For one-time, format as datetime-local (local time, not UTC)
|
||||
const startDate = new Date(editingRecord.start)
|
||||
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
|
||||
|
||||
setStartDateTime(formatDateTimeLocal(startDate))
|
||||
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
|
||||
}
|
||||
} else {
|
||||
// Reset form with default dates: today at 12pm and 1pm
|
||||
const today = new Date()
|
||||
const noon = new Date(today)
|
||||
noon.setHours(12, 0, 0, 0)
|
||||
const onePm = new Date(today)
|
||||
onePm.setHours(13, 0, 0, 0)
|
||||
|
||||
setSelectedSystem("")
|
||||
setIsGlobal(true)
|
||||
setWindowType("one-time")
|
||||
setStartDateTime(formatDateTimeLocal(noon))
|
||||
setEndDateTime(formatDateTimeLocal(onePm))
|
||||
setStartTime("12:00")
|
||||
setEndTime("13:00")
|
||||
}
|
||||
}, [editingRecord])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
let startValue: string
|
||||
let endValue: string | undefined
|
||||
|
||||
if (windowType === "daily") {
|
||||
// For daily windows, convert local time to UTC
|
||||
// Use today's date so the current DST offset is applied (not a fixed historical date)
|
||||
const today = new Date().toISOString().split("T")[0]
|
||||
const startDate = new Date(`${today}T${startTime}:00`)
|
||||
startValue = startDate.toISOString()
|
||||
|
||||
if (endTime) {
|
||||
const endDate = new Date(`${today}T${endTime}:00`)
|
||||
endValue = endDate.toISOString()
|
||||
}
|
||||
} else {
|
||||
// For one-time windows, use the datetime values
|
||||
startValue = new Date(startDateTime).toISOString()
|
||||
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: pb.authStore.record?.id,
|
||||
system: isGlobal ? undefined : selectedSystem,
|
||||
type: windowType,
|
||||
start: startValue,
|
||||
end: endValue,
|
||||
}
|
||||
|
||||
if (editingRecord) {
|
||||
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
||||
} else {
|
||||
await pb.collection("quiet_hours").create(data)
|
||||
}
|
||||
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t`Error`,
|
||||
description: t`Failed to save settings`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRecord ? (
|
||||
<Trans>Edit {{ foo: quietHoursTranslation }}</Trans>
|
||||
) : (
|
||||
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Schedule quiet hours where notifications will not be sent.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="global">
|
||||
<Trans>Global</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system">
|
||||
<Trans>System</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="system">
|
||||
<Trans>System</Trans>
|
||||
</Label>
|
||||
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
||||
<SelectTrigger id="system">
|
||||
<SelectValue placeholder={t`Select ${{ foo: t`System`.toLocaleLowerCase() }}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{systems.map((system) => (
|
||||
<SelectItem key={system.id} value={system.id}>
|
||||
{system.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Hidden input for native form validation */}
|
||||
<input
|
||||
className="sr-only"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={selectedSystem}
|
||||
onChange={() => {}}
|
||||
required={!isGlobal}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">
|
||||
<Trans>Type</Trans>
|
||||
</Label>
|
||||
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one-time">
|
||||
<Trans>One-time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="daily">
|
||||
<Trans>Daily</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{windowType === "one-time" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="start-datetime">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="start-datetime"
|
||||
type="datetime-local"
|
||||
value={startDateTime}
|
||||
onChange={(e) => setStartDateTime(e.target.value)}
|
||||
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="end-datetime">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="end-datetime"
|
||||
type="datetime-local"
|
||||
value={endDateTime}
|
||||
onChange={(e) => setEndDateTime(e.target.value)}
|
||||
min={startDateTime || formatDateTimeLocal(new Date())}
|
||||
required
|
||||
className="tabular-nums tracking-tighter"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2 grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="start-time">
|
||||
<Trans>Start Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="start-time"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end-time">
|
||||
<Trans>End Time</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
className="tabular-nums tracking-tighter"
|
||||
id="end-time"
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useStore } from "@nanostores/react"
|
||||
import type React from "react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { isAdmin, isReadOnlyUser } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { $router, Link, navigate } from "../../router"
|
||||
import { buttonVariants } from "../../ui/button"
|
||||
|
||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
items: {
|
||||
href: string
|
||||
title: string
|
||||
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||
admin?: boolean
|
||||
noReadOnly?: boolean
|
||||
preload?: () => Promise<{ default: React.ComponentType<any> }>
|
||||
}[]
|
||||
}
|
||||
|
||||
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
const page = useStore($router)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile View */}
|
||||
<div className="md:hidden">
|
||||
<Select onValueChange={navigate} value={page?.path}>
|
||||
<SelectTrigger className="w-full my-3.5">
|
||||
<SelectValue placeholder="Select page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => {
|
||||
if (item.admin && !isAdmin()) return null
|
||||
return (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{item.icon && <item.icon className="size-4" />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Desktop View */}
|
||||
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
|
||||
{items.map((item) => {
|
||||
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
onMouseEnter={() => item.preload?.()}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"flex items-center gap-3 justify-start truncate duration-50",
|
||||
page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="size-4 shrink-0" />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { redirectPage } from "@nanostores/router"
|
||||
import {
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
FingerprintIcon,
|
||||
KeyIcon,
|
||||
MoreHorizontalIcon,
|
||||
RotateCwIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
copyDockerCompose,
|
||||
copyDockerRun,
|
||||
copyLinuxCommand,
|
||||
copyWindowsCommand,
|
||||
type DropdownItem,
|
||||
InstallDropdown,
|
||||
} from "@/components/install-dropdowns"
|
||||
import { $router } from "@/components/router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import { $publicKey } from "@/lib/stores"
|
||||
import { cn, copyToClipboard, generateToken, getHubURL, tokenMap } from "@/lib/utils"
|
||||
import type { FingerprintRecord } from "@/types"
|
||||
|
||||
const pbFingerprintOptions = {
|
||||
expand: "system",
|
||||
fields: "id,fingerprint,token,system,expand.system.name",
|
||||
}
|
||||
|
||||
function sortFingerprints(fingerprints: FingerprintRecord[]) {
|
||||
return fingerprints.sort((a, b) => a.expand.system.name.localeCompare(b.expand.system.name))
|
||||
}
|
||||
|
||||
const SettingsFingerprintsPage = memo(() => {
|
||||
if (isReadOnlyUser()) {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
}
|
||||
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
|
||||
|
||||
// Get fingerprint records on mount
|
||||
useEffect(() => {
|
||||
pb.collection("fingerprints")
|
||||
.getFullList<FingerprintRecord>(pbFingerprintOptions)
|
||||
.then((prints) => {
|
||||
setFingerprints(sortFingerprints(prints))
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 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 sortFingerprints([...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)
|
||||
const [isPermanent, setIsPermanent] = useState(false)
|
||||
|
||||
async function updateToken(enable: number = -1, permanent: 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,
|
||||
permanent,
|
||||
},
|
||||
})
|
||||
setToken(data.token)
|
||||
setChecked(data.active)
|
||||
setIsPermanent(!!data.permanent)
|
||||
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.</Trans>
|
||||
</p>
|
||||
<div className="mt-3 border rounded-md px-4 py-3 max-w-full">
|
||||
{!isLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(checked) => {
|
||||
// Keep current permanence preference when enabling/disabling
|
||||
updateToken(checked ? 1 : 0, isPermanent ? 1 : 0)
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 overflow-auto">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-primary opacity-60 transition-opacity",
|
||||
checked ? "opacity-100" : "select-none"
|
||||
)}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
</div>
|
||||
<ActionsButtonUniversalToken token={token} checked={checked} />
|
||||
</div>
|
||||
|
||||
{checked && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-sm font-medium">
|
||||
<Trans>Persistence</Trans>
|
||||
</div>
|
||||
<Tabs
|
||||
value={isPermanent ? "permanent" : "ephemeral"}
|
||||
onValueChange={(value) => updateToken(1, value === "permanent" ? 1 : 0)}
|
||||
className="mt-2"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger className="xs:min-w-40" value="ephemeral">
|
||||
<Trans>Ephemeral</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="xs:min-w-40" value="permanent">
|
||||
<Trans>Permanent</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="ephemeral" className="mt-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Expires after one hour or on hub restart.</Trans>
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="permanent" className="mt-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans>Saved in the database and does not expire until you disable it.</Trans>
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; checked: boolean }) => {
|
||||
const { t } = useLingui()
|
||||
const publicKey = $publicKey.get()
|
||||
const port = "45876"
|
||||
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
text: t({ message: "Copy docker compose", context: "Button to copy docker compose file content" }),
|
||||
onClick: () => copyDockerCompose(port, publicKey, token),
|
||||
icons: [DockerIcon],
|
||||
},
|
||||
{
|
||||
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
|
||||
onClick: () => copyDockerRun(port, publicKey, token),
|
||||
icons: [DockerIcon],
|
||||
},
|
||||
{
|
||||
text: t`Copy Linux command`,
|
||||
onClick: () => copyLinuxCommand(port, publicKey, token),
|
||||
icons: [TuxIcon],
|
||||
},
|
||||
{
|
||||
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
|
||||
onClick: () => copyLinuxCommand(port, publicKey, token, true),
|
||||
icons: [TuxIcon, AppleIcon],
|
||||
},
|
||||
{
|
||||
text: t({ message: "Windows command", context: "Button to copy install command" }),
|
||||
onClick: () => copyWindowsCommand(port, publicKey, token),
|
||||
icons: [WindowsIcon],
|
||||
},
|
||||
{
|
||||
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
|
||||
onClick: () => copyLinuxCommand(port, publicKey, token),
|
||||
icons: [FreeBsdIcon],
|
||||
},
|
||||
{
|
||||
text: t`Manual setup instructions`,
|
||||
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||
icons: [ExternalLinkIcon],
|
||||
},
|
||||
]
|
||||
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 { t } = useLingui()
|
||||
const isReadOnly = isReadOnlyUser()
|
||||
|
||||
const headerCols = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t`System`,
|
||||
Icon: ServerIcon,
|
||||
w: "11em",
|
||||
},
|
||||
{
|
||||
label: t`Token`,
|
||||
Icon: KeyIcon,
|
||||
w: "20em",
|
||||
},
|
||||
{
|
||||
label: t`Fingerprint`,
|
||||
Icon: FingerprintIcon,
|
||||
w: "20em",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden w-full mt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr className="border-border/50">
|
||||
{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>
|
||||
)}
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody className="whitespace-pre">
|
||||
{fingerprints.map((fingerprint) => (
|
||||
<TableRow key={fingerprint.id}>
|
||||
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
|
||||
{fingerprint.expand.system.name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.token}</TableCell>
|
||||
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.fingerprint}</TableCell>
|
||||
{!isReadOnly && (
|
||||
<TableCell className="py-2 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: unknown) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: (error as 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
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react"
|
||||
import SmartTable from "@/components/routes/system/smart-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default function Smart() {
|
||||
useEffect(() => {
|
||||
document.title = `S.M.A.R.T. / Beszel`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<SmartTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { memo, useState } from "react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
||||
import type { GPUData } from "@/types"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import InfoBar from "./system/info-bar"
|
||||
import { useSystemData } from "./system/use-system-data"
|
||||
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
||||
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
|
||||
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||
import { GpuIcon } from "../ui/icons"
|
||||
import SystemdTable from "../systemd-table/systemd-table"
|
||||
import ContainersTable from "../containers-table/containers-table"
|
||||
|
||||
const SEMVER_0_14_0 = parseSemVer("0.14.0")
|
||||
const SEMVER_0_15_0 = parseSemVer("0.15.0")
|
||||
|
||||
export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const systemData = useSystemData(id)
|
||||
|
||||
const {
|
||||
system,
|
||||
systemStats,
|
||||
containerData,
|
||||
chartData,
|
||||
containerChartConfigs,
|
||||
details,
|
||||
grid,
|
||||
setGrid,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
mountedTabs,
|
||||
tabsRef,
|
||||
maxValues,
|
||||
isLongerChart,
|
||||
showMax,
|
||||
dataEmpty,
|
||||
isPodman,
|
||||
lastGpus,
|
||||
hasGpuData,
|
||||
hasGpuEnginesData,
|
||||
hasGpuPowerData,
|
||||
} = systemData
|
||||
|
||||
// extra margin to add to bottom of page, specifically for temperature chart,
|
||||
// where the tooltip can go past the bottom of the page if lots of sensors
|
||||
const [pageBottomExtraMargin, setPageBottomExtraMargin] = useState(0)
|
||||
|
||||
if (!system.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasContainers = containerData.length > 0
|
||||
const maybeHasSmartData = compareSemVer(chartData.agentVersion, SEMVER_0_15_0) >= 0
|
||||
const hasContainersTable = hasContainers && compareSemVer(chartData.agentVersion, SEMVER_0_14_0) >= 0
|
||||
const hasSystemd = system.info.sv
|
||||
const hasGpu = hasGpuData || hasGpuPowerData
|
||||
|
||||
// keep tabsRef in sync for keyboard navigation
|
||||
const tabs = ["core", "disk"]
|
||||
if (hasGpu) tabs.push("gpu")
|
||||
if (hasContainers) tabs.push("containers")
|
||||
if (hasSystemd) tabs.push("services")
|
||||
tabsRef.current = tabs
|
||||
|
||||
// shared chart props
|
||||
const coreProps = { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues }
|
||||
|
||||
function defaultLayout() {
|
||||
return (
|
||||
<>
|
||||
{/* main charts */}
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<CpuChart {...coreProps} />
|
||||
|
||||
{hasContainers && (
|
||||
<ContainerCpuChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
cpuConfig={containerChartConfigs.cpu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MemoryChart {...coreProps} />
|
||||
|
||||
{hasContainers && (
|
||||
<ContainerMemoryChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
memoryConfig={containerChartConfigs.memory}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RootDiskCharts systemData={systemData} />
|
||||
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
|
||||
{hasContainers && (
|
||||
<ContainerNetworkChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
networkConfig={containerChartConfigs.network}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
|
||||
<TemperatureChart {...coreProps} />
|
||||
|
||||
<BatteryChart {...coreProps} />
|
||||
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
</div>
|
||||
|
||||
{hasGpuData && lastGpus && (
|
||||
<GpuDetailCharts
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
lastGpus={lastGpus as Record<string, GPUData>}
|
||||
hasGpuEnginesData={hasGpuEnginesData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExtraFsCharts systemData={systemData} />
|
||||
|
||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||
|
||||
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
||||
|
||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function tabbedLayout() {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="contents">
|
||||
<TabsList className="h-11 p-1.5 w-full shadow-xs overflow-auto justify-start">
|
||||
<TabsTrigger value="core" className="w-full flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5" />
|
||||
<Trans context="Core system metrics">Core</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />
|
||||
<Trans>Disk</Trans>
|
||||
</TabsTrigger>
|
||||
{hasGpu && (
|
||||
<TabsTrigger value="gpu" className="w-full flex items-center gap-2">
|
||||
<GpuIcon className="size-3.5" />
|
||||
<Trans>GPU</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{hasContainers && (
|
||||
<TabsTrigger value="containers" className="w-full flex items-center gap-2">
|
||||
<ContainerIcon className="size-3.5" />
|
||||
<Trans>Containers</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{hasSystemd && (
|
||||
<TabsTrigger value="services" className="w-full flex items-center gap-2">
|
||||
<TerminalSquareIcon className="size-3.5" />
|
||||
<Trans>Services</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<CpuChart {...coreProps} />
|
||||
<MemoryChart {...coreProps} />
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("disk") && (
|
||||
<>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<RootDiskCharts systemData={systemData} />
|
||||
</div>
|
||||
<ExtraFsCharts systemData={systemData} />
|
||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{hasGpu && (
|
||||
<TabsContent value="gpu" forceMount className={activeTab === "gpu" ? "contents" : "hidden"}>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||
</div>
|
||||
{hasGpuData && lastGpus && (
|
||||
<GpuDetailCharts
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
lastGpus={lastGpus as Record<string, GPUData>}
|
||||
hasGpuEnginesData={hasGpuEnginesData}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasContainers && (
|
||||
<TabsContent value="containers" forceMount className={activeTab === "containers" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("containers") && (
|
||||
<>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<ContainerCpuChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
cpuConfig={containerChartConfigs.cpu}
|
||||
/>
|
||||
<ContainerMemoryChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
memoryConfig={containerChartConfigs.memory}
|
||||
/>
|
||||
<ContainerNetworkChart
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
dataEmpty={dataEmpty}
|
||||
isPodman={isPodman}
|
||||
networkConfig={containerChartConfigs.network}
|
||||
/>
|
||||
</div>
|
||||
{hasContainersTable && <ContainersTable systemId={system.id} />}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasSystemd && (
|
||||
<TabsContent value="services" forceMount className={activeTab === "services" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("services") && <SystemdTable systemId={system.id} />}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 mb-14 overflow-x-clip">
|
||||
{/* system info */}
|
||||
<InfoBar
|
||||
system={system}
|
||||
chartData={chartData}
|
||||
grid={grid}
|
||||
setGrid={setGrid}
|
||||
displayMode={displayMode}
|
||||
setDisplayMode={setDisplayMode}
|
||||
details={details}
|
||||
/>
|
||||
|
||||
{displayMode === "tabs" ? tabbedLayout() : defaultLayout()}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import React, { type JSX, memo, useCallback, useEffect, useState } from "react"
|
||||
import { $containerFilter, $maxValues } from "@/lib/stores"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Spinner from "../../spinner"
|
||||
import { Button } from "../../ui/button"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "../../ui/card"
|
||||
import { ChartAverage, ChartMax } from "../../ui/icons"
|
||||
import { Input } from "../../ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
|
||||
|
||||
export function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||
const storeValue = useStore(store)
|
||||
const [inputValue, setInputValue] = useState(storeValue)
|
||||
const { t } = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(storeValue)
|
||||
}, [storeValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue === storeValue) {
|
||||
return
|
||||
}
|
||||
const handle = window.setTimeout(() => store.set(inputValue), 80)
|
||||
return () => clearTimeout(handle)
|
||||
}, [inputValue, storeValue, store])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInputValue(value)
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInputValue("")
|
||||
store.set("")
|
||||
}, [store])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
className="ps-4 pe-8 w-full sm:w-44"
|
||||
onChange={handleChange}
|
||||
value={inputValue}
|
||||
/>
|
||||
{inputValue && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Clear"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectAvgMax = memo(({ max }: { max: boolean }) => {
|
||||
const Icon = max ? ChartMax : ChartAverage
|
||||
return (
|
||||
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
|
||||
<SelectTrigger className="relative ps-10 pe-5 w-full sm:w-44">
|
||||
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="avg" value="avg">
|
||||
<Trans>Average</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem key="max" value="max">
|
||||
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
})
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
grid,
|
||||
empty,
|
||||
cornerEl,
|
||||
legend,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
grid?: boolean
|
||||
empty?: boolean
|
||||
cornerEl?: JSX.Element | null
|
||||
legend?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"px-3 py-5 sm:py-6 sm:px-6 odd:last-of-type:col-span-full min-h-full",
|
||||
{ "col-span-full": !grid },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<CardHeader className="gap-1.5 relative p-0 mb-3 sm:mb-4">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{cornerEl && <div className="grid sm:justify-end sm:absolute sm:top-0 sm:end-0 my-1 sm:my-0">{cornerEl}</div>}
|
||||
</CardHeader>
|
||||
<div className={cn("ps-0 -me-1 -ms-3.5 relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
||||
{
|
||||
<Spinner
|
||||
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||
className="group-has-[.opacity-100]:invisible duration-100"
|
||||
/>
|
||||
}
|
||||
{isIntersecting && children}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { timeTicks } from "d3-time"
|
||||
import { getPbTimestamp, pb } from "@/lib/api"
|
||||
import { chartTimeData } from "@/lib/utils"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||
|
||||
type ChartTimeData = {
|
||||
time: number
|
||||
data: {
|
||||
ticks: number[]
|
||||
domain: number[]
|
||||
}
|
||||
chartTime: ChartTimes
|
||||
}
|
||||
|
||||
export const cache = new Map<
|
||||
string,
|
||||
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
|
||||
>()
|
||||
|
||||
// create ticks and domain for charts
|
||||
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
||||
const cached = cache.get("td") as ChartTimeData | undefined
|
||||
if (cached && cached.chartTime === chartTime) {
|
||||
if (!lastCreated || cached.time >= lastCreated) {
|
||||
return cached.data
|
||||
}
|
||||
}
|
||||
|
||||
// const buffer = chartTime === "1m" ? 400 : 20_000
|
||||
const now = new Date(Date.now())
|
||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||
const data = {
|
||||
ticks,
|
||||
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
|
||||
}
|
||||
cache.set("td", { time: now.getTime(), data, chartTime })
|
||||
return data
|
||||
}
|
||||
|
||||
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
|
||||
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
|
||||
export function appendData<T extends { created: string | number | null }>(
|
||||
prev: T[],
|
||||
newRecords: T[],
|
||||
expectedInterval: number,
|
||||
maxLen?: number
|
||||
): T[] {
|
||||
if (!newRecords.length) return prev
|
||||
// Pre-trim prev so the single slice() below is the only copy we make
|
||||
const trimmed = maxLen && prev.length >= maxLen ? prev.slice(-(maxLen - newRecords.length)) : prev
|
||||
const result = trimmed.slice()
|
||||
let prevTime = (trimmed.at(-1)?.created as number) ?? 0
|
||||
for (const record of newRecords) {
|
||||
if (record.created !== null) {
|
||||
if (typeof record.created === "string") {
|
||||
record.created = new Date(record.created).getTime()
|
||||
}
|
||||
if (prevTime && (record.created as number) - prevTime > expectedInterval * 1.5) {
|
||||
result.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
|
||||
}
|
||||
prevTime = record.created as number
|
||||
}
|
||||
result.push(record)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
collection: string,
|
||||
systemId: string,
|
||||
chartTime: ChartTimes
|
||||
): Promise<T[]> {
|
||||
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||
const lastCached = cachedStats?.at(-1)?.created as number
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||
id: systemId,
|
||||
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
|
||||
type: chartTimeData[chartTime].type,
|
||||
}),
|
||||
fields: "created,stats",
|
||||
sort: "created",
|
||||
})
|
||||
}
|
||||
|
||||
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
|
||||
const result = [] as ChartData["containerData"]
|
||||
for (const { created, stats } of containers) {
|
||||
if (!created) {
|
||||
result.push({ created: null } as ChartData["containerData"][0])
|
||||
continue
|
||||
}
|
||||
result.push(makeContainerPoint(new Date(created).getTime(), stats))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Transform a single realtime container stats message into a ChartDataContainer point. */
|
||||
export function makeContainerPoint(
|
||||
created: number,
|
||||
stats: ContainerStatsRecord["stats"]
|
||||
): ChartData["containerData"][0] {
|
||||
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
|
||||
for (const container of stats) {
|
||||
;(point as Record<string, unknown>)[container.n] = container
|
||||
}
|
||||
return point
|
||||
}
|
||||
|
||||
export function dockerOrPodman(str: string, isPodman: boolean): string {
|
||||
if (isPodman) {
|
||||
return str.replace("docker", "podman").replace("Docker", "Podman")
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { useContainerDataPoints } from "@/components/charts/hooks"
|
||||
import { decimalString, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartConfig } from "@/components/ui/chart"
|
||||
import type { ChartData } from "@/types"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import CpuCoresSheet from "../cpu-sheet"
|
||||
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
|
||||
import { dockerOrPodman } from "../chart-data"
|
||||
|
||||
export function CpuChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
showMax,
|
||||
isLongerChart,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
showMax: boolean
|
||||
isLongerChart: boolean
|
||||
maxValues: boolean
|
||||
}) {
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`CPU Usage`}
|
||||
description={t`Average system-wide CPU utilization`}
|
||||
cornerEl={
|
||||
<div className="flex gap-2">
|
||||
{maxValSelect}
|
||||
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`CPU Usage`,
|
||||
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
domain={pinnedAxisDomain()}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContainerCpuChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
isPodman,
|
||||
cpuConfig,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
isPodman: boolean
|
||||
cpuConfig: ChartConfig
|
||||
}) {
|
||||
const { filter, dataPoints } = useContainerDataPoints(cpuConfig, (key, data) => data[key]?.c ?? null)
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
|
||||
description={t`Average CPU utilization of containers`}
|
||||
cornerEl={<FilterBar />}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
customData={chartData.containerData}
|
||||
dataPoints={dataPoints}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
domain={pinnedAxisDomain()}
|
||||
showTotal={true}
|
||||
reverseStackOrder={true}
|
||||
filter={filter}
|
||||
truncate={true}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, SelectAvgMax } from "../chart-card"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import DiskIoSheet from "../disk-io-sheet"
|
||||
import type { SystemData } from "../use-system-data"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
|
||||
// Helpers for indexed dios/diosm access
|
||||
const dios =
|
||||
(i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.dios?.[i] ?? 0
|
||||
const diosMax =
|
||||
(i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.diosm?.[i] ?? 0
|
||||
const extraDios =
|
||||
(name: string, i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.dios?.[i] ?? 0
|
||||
const extraDiosMax =
|
||||
(name: string, i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.diosm?.[i] ?? 0
|
||||
|
||||
export const diskDataFns = {
|
||||
// usage
|
||||
usage: ({ stats }: SystemStatsRecord) => stats?.du ?? 0,
|
||||
extraUsage:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.du ?? 0,
|
||||
// throughput
|
||||
read: ({ stats }: SystemStatsRecord) => stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024,
|
||||
readMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024,
|
||||
write: ({ stats }: SystemStatsRecord) => stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024,
|
||||
writeMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024,
|
||||
// extra fs throughput
|
||||
extraRead:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.rb ?? (stats?.efs?.[name]?.r ?? 0) * 1024 * 1024,
|
||||
extraReadMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.rbm ?? (stats?.efs?.[name]?.rm ?? 0) * 1024 * 1024,
|
||||
extraWrite:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.wb ?? (stats?.efs?.[name]?.w ?? 0) * 1024 * 1024,
|
||||
extraWriteMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.wbm ?? (stats?.efs?.[name]?.wm ?? 0) * 1024 * 1024,
|
||||
// read/write time
|
||||
readTime: dios(0),
|
||||
readTimeMax: diosMax(0),
|
||||
extraReadTime: (name: string) => extraDios(name, 0),
|
||||
extraReadTimeMax: (name: string) => extraDiosMax(name, 0),
|
||||
writeTime: dios(1),
|
||||
writeTimeMax: diosMax(1),
|
||||
extraWriteTime: (name: string) => extraDios(name, 1),
|
||||
extraWriteTimeMax: (name: string) => extraDiosMax(name, 1),
|
||||
// utilization (IoTime-based, 0-100%)
|
||||
util: dios(2),
|
||||
utilMax: diosMax(2),
|
||||
extraUtil: (name: string) => extraDios(name, 2),
|
||||
extraUtilMax: (name: string) => extraDiosMax(name, 2),
|
||||
// r_await / w_await: average service time per read/write operation (ms)
|
||||
rAwait: dios(3),
|
||||
rAwaitMax: diosMax(3),
|
||||
extraRAwait: (name: string) => extraDios(name, 3),
|
||||
extraRAwaitMax: (name: string) => extraDiosMax(name, 3),
|
||||
wAwait: dios(4),
|
||||
wAwaitMax: diosMax(4),
|
||||
extraWAwait: (name: string) => extraDios(name, 4),
|
||||
extraWAwaitMax: (name: string) => extraDiosMax(name, 4),
|
||||
// average queue depth: stored as queue_depth * 100 in Go, divided here
|
||||
weightedIO: ({ stats }: SystemStatsRecord) => (stats?.dios?.[5] ?? 0) / 100,
|
||||
weightedIOMax: ({ stats }: SystemStatsRecord) => (stats?.diosm?.[5] ?? 0) / 100,
|
||||
extraWeightedIO:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
(stats?.efs?.[name]?.dios?.[5] ?? 0) / 100,
|
||||
extraWeightedIOMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
(stats?.efs?.[name]?.diosm?.[5] ?? 0) / 100,
|
||||
}
|
||||
|
||||
export function RootDiskCharts({ systemData }: { systemData: SystemData }) {
|
||||
return (
|
||||
<>
|
||||
<DiskUsageChart systemData={systemData} />
|
||||
<DiskIOChart systemData={systemData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskUsageChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty } = systemData
|
||||
|
||||
let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN
|
||||
if (extraFsName) {
|
||||
diskSize = chartData.systemStats?.at(-1)?.stats.efs?.[extraFsName]?.d ?? NaN
|
||||
}
|
||||
// round to nearest GB
|
||||
if (diskSize >= 100) {
|
||||
diskSize = Math.round(diskSize)
|
||||
}
|
||||
|
||||
const title = extraFsName ? `${extraFsName} ${t`Usage`}` : t`Disk Usage`
|
||||
const description = extraFsName ? t`Disk usage of ${extraFsName}` : t`Usage of root partition`
|
||||
|
||||
return (
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, diskSize]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Disk Usage`,
|
||||
color: 4,
|
||||
opacity: 0.4,
|
||||
dataKey: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage,
|
||||
},
|
||||
]}
|
||||
></AreaChartDefault>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
if (!chartData.systemStats?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
|
||||
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
|
||||
|
||||
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
|
||||
|
||||
let CornerEl = maxValSelect
|
||||
if (hasMoreIOMetrics) {
|
||||
CornerEl = (
|
||||
<div className="flex gap-2">
|
||||
{maxValSelect}
|
||||
<DiskIoSheet systemData={systemData} extraFsName={extraFsName} title={title} description={description} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
|
||||
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
|
||||
if (extraFsName) {
|
||||
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
|
||||
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description} cornerEl={CornerEl}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
// domain={pinnedAxisDomain(true)}
|
||||
showTotal={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Write", comment: "Disk write" }),
|
||||
dataKey: writeFn,
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t({ message: "Read", comment: "Disk read" }),
|
||||
dataKey: readFn,
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskUtilizationChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
|
||||
if (!chartData.systemStats?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let utilFn = showMax ? diskDataFns.utilMax : diskDataFns.util
|
||||
if (extraFsName) {
|
||||
utilFn = showMax ? diskDataFns.extraUtilMax(extraFsName) : diskDataFns.extraUtil(extraFsName)
|
||||
}
|
||||
return (
|
||||
<ChartCard
|
||||
cornerEl={maxValSelect}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({
|
||||
message: `I/O Utilization`,
|
||||
context: "Percent of time the disk is busy with I/O",
|
||||
})}
|
||||
description={t`Percent of time the disk is busy with I/O`}
|
||||
// legend={true}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
maxToggled={showMax}
|
||||
chartProps={{ syncId: "io" }}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Utilization", context: "Disk I/O utilization" }),
|
||||
dataKey: utilFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExtraFsCharts({ systemData }: { systemData: SystemData }) {
|
||||
const { systemStats } = systemData.chartData
|
||||
|
||||
const extraFs = systemStats?.at(-1)?.stats.efs
|
||||
|
||||
if (!extraFs || Object.keys(extraFs).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{Object.keys(extraFs).map((extraFsName) => {
|
||||
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
|
||||
// round to nearest GB
|
||||
if (diskSize >= 100) {
|
||||
diskSize = Math.round(diskSize)
|
||||
}
|
||||
return (
|
||||
<div key={extraFsName} className="contents">
|
||||
<DiskUsageChart systemData={systemData} extraFsName={extraFsName} />
|
||||
|
||||
<DiskIOChart systemData={systemData} extraFsName={extraFsName} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useRef, useMemo } from "react"
|
||||
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard } from "../chart-card"
|
||||
|
||||
/** GPU power draw chart for the main grid */
|
||||
export function GpuPowerChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
}) {
|
||||
const packageKey = " package"
|
||||
const statsRef = useRef(chartData.systemStats)
|
||||
statsRef.current = chartData.systemStats
|
||||
|
||||
// Derive GPU power config key (cheap per render)
|
||||
let gpuPowerKey = ""
|
||||
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
|
||||
const gpus = chartData.systemStats[i].stats?.g
|
||||
if (gpus) {
|
||||
const parts: string[] = []
|
||||
for (const id in gpus) {
|
||||
const gpu = gpus[id] as GPUData
|
||||
if (gpu.p !== undefined) parts.push(`${id}:${gpu.n}`)
|
||||
if (gpu.pp !== undefined) parts.push(`${id}:${gpu.n}${packageKey}`)
|
||||
}
|
||||
gpuPowerKey = parts.sort().join("\0")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const dataPoints = useMemo((): DataPoint[] => {
|
||||
if (!gpuPowerKey) return []
|
||||
const totals = new Map<string, { label: string; gpuId: string; isPackage: boolean; total: number }>()
|
||||
for (const record of statsRef.current) {
|
||||
const gpus = record.stats?.g
|
||||
if (!gpus) continue
|
||||
for (const id in gpus) {
|
||||
const gpu = gpus[id] as GPUData
|
||||
const key = gpu.n
|
||||
const existing = totals.get(key)
|
||||
if (existing) {
|
||||
existing.total += gpu.p ?? 0
|
||||
} else {
|
||||
totals.set(key, { label: gpu.n, gpuId: id, isPackage: false, total: gpu.p ?? 0 })
|
||||
}
|
||||
if (gpu.pp !== undefined) {
|
||||
const pkgKey = `${gpu.n}${packageKey}`
|
||||
const existingPkg = totals.get(pkgKey)
|
||||
if (existingPkg) {
|
||||
existingPkg.total += gpu.pp
|
||||
} else {
|
||||
totals.set(pkgKey, { label: pkgKey, gpuId: id, isPackage: true, total: gpu.pp })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(totals.values()).sort((a, b) => b.total - a.total)
|
||||
return sorted.map(
|
||||
(entry, i): DataPoint => ({
|
||||
label: entry.label,
|
||||
dataKey: (data: SystemStatsRecord) => {
|
||||
const gpu = data.stats?.g?.[entry.gpuId]
|
||||
return entry.isPackage ? (gpu?.pp ?? 0) : (gpu?.p ?? 0)
|
||||
},
|
||||
color: `hsl(${226 + (((i * 360) / sorted.length) % 360)}, 65%, 52%)`,
|
||||
opacity: 1,
|
||||
})
|
||||
)
|
||||
}, [gpuPowerKey])
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`GPU Power Draw`}
|
||||
description={t`Average power consumption of GPUs`}
|
||||
>
|
||||
<LineChartDefault
|
||||
legend={dataPoints.length > 1}
|
||||
chartData={chartData}
|
||||
dataPoints={dataPoints}
|
||||
itemSorter={(a: { value: number }, b: { value: number }) => b.value - a.value}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}W`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}W`}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
/** GPU detail grid (engines + per-GPU usage/VRAM) — rendered outside the main 2-col grid */
|
||||
export function GpuDetailCharts({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
lastGpus,
|
||||
hasGpuEnginesData,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
lastGpus: Record<string, GPUData>
|
||||
hasGpuEnginesData: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{hasGpuEnginesData && (
|
||||
<ChartCard
|
||||
legend={true}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`GPU Engines`}
|
||||
description={t`Average utilization of GPU engines`}
|
||||
>
|
||||
<GpuEnginesChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{Object.keys(lastGpus).map((id) => {
|
||||
const gpu = lastGpus[id] as GPUData
|
||||
return (
|
||||
<div key={id} className="contents">
|
||||
<ChartCard
|
||||
className={cn(grid && "!col-span-1")}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${gpu.n} ${t`Usage`}`}
|
||||
description={t`Average utilization of ${gpu.n}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||
color: 1,
|
||||
opacity: 0.35,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{(gpu.mt ?? 0) > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${gpu.n} VRAM`}
|
||||
description={t`Precise utilization at the recorded time`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||
color: 2,
|
||||
opacity: 0.25,
|
||||
},
|
||||
]}
|
||||
max={gpu.mt}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
||||
// Derive stable engine config key (cheap per render)
|
||||
let enginesKey = ""
|
||||
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
|
||||
const gpus = chartData.systemStats[i].stats?.g
|
||||
if (!gpus) continue
|
||||
for (const id in gpus) {
|
||||
if (gpus[id].e) {
|
||||
enginesKey = id + "\0" + Object.keys(gpus[id].e).sort().join("\0")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (enginesKey) break
|
||||
}
|
||||
|
||||
const { gpuId, dataPoints } = useMemo((): { gpuId: string | null; dataPoints: DataPoint[] } => {
|
||||
if (!enginesKey) return { gpuId: null, dataPoints: [] }
|
||||
const parts = enginesKey.split("\0")
|
||||
const gId = parts[0]
|
||||
const engineNames = parts.slice(1)
|
||||
return {
|
||||
gpuId: gId,
|
||||
dataPoints: engineNames.map((engine, i) => ({
|
||||
label: engine,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gId]?.e?.[engine] ?? 0,
|
||||
color: `hsl(${140 + (((i * 360) / engineNames.length) % 360)}, 65%, 52%)`,
|
||||
opacity: 0.35,
|
||||
})),
|
||||
}
|
||||
}, [enginesKey])
|
||||
|
||||
if (!gpuId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<LineChartDefault
|
||||
legend={true}
|
||||
chartData={chartData}
|
||||
dataPoints={dataPoints}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { ChartData } from "@/types"
|
||||
import { ChartCard } from "../chart-card"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { decimalString, toFixedFloat } from "@/lib/utils"
|
||||
|
||||
export function LoadAverageChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
}) {
|
||||
const { major, minor } = chartData.agentVersion
|
||||
if (major === 0 && minor <= 12) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Load Average`}
|
||||
description={t`System load averages over time`}
|
||||
legend={true}
|
||||
>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
contentFormatter={(item) => decimalString(item.value)}
|
||||
tickFormatter={(value) => {
|
||||
return String(toFixedFloat(value, 2))
|
||||
}}
|
||||
legend={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: `1 min`, comment: "Load average" }),
|
||||
color: "hsl(271, 81%, 60%)", // Purple
|
||||
dataKey: ({ stats }) => stats?.la?.[0],
|
||||
},
|
||||
{
|
||||
label: t({ message: `5 min`, comment: "Load average" }),
|
||||
color: "hsl(217, 91%, 60%)", // Blue
|
||||
dataKey: ({ stats }) => stats?.la?.[1],
|
||||
},
|
||||
{
|
||||
label: t({ message: `15 min`, comment: "Load average" }),
|
||||
color: "hsl(25, 95%, 53%)", // Orange
|
||||
dataKey: ({ stats }) => stats?.la?.[2],
|
||||
},
|
||||
]}
|
||||
></LineChartDefault>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { useContainerDataPoints } from "@/components/charts/hooks"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import type { ChartConfig } from "@/components/ui/chart"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
|
||||
import { dockerOrPodman } from "../chart-data"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
|
||||
export function MemoryChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
showMax,
|
||||
isLongerChart,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
showMax: boolean
|
||||
isLongerChart: boolean
|
||||
maxValues: boolean
|
||||
}) {
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Memory Usage`}
|
||||
description={t`Precise utilization at the recorded time`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, totalMem]}
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
maxToggled={showMax}
|
||||
showTotal={true}
|
||||
tickFormatter={(value) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Used`,
|
||||
dataKey: ({ stats }) => (showMax ? stats?.mm : stats?.mu),
|
||||
color: 2,
|
||||
opacity: 0.4,
|
||||
stackId: "1",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
label: "ZFS ARC",
|
||||
dataKey: ({ stats }) => (showMax ? null : stats?.mz),
|
||||
color: "hsla(175 60% 45% / 0.8)",
|
||||
opacity: 0.5,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: t`Cache / Buffers`,
|
||||
dataKey: ({ stats }) => (showMax ? null : stats?.mb),
|
||||
color: "hsla(160 60% 45% / 0.5)",
|
||||
opacity: 0.4,
|
||||
stackId: "1",
|
||||
order: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContainerMemoryChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
isPodman,
|
||||
memoryConfig,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
isPodman: boolean
|
||||
memoryConfig: ChartConfig
|
||||
}) {
|
||||
const { filter, dataPoints } = useContainerDataPoints(memoryConfig, (key, data) => data[key]?.m ?? null)
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
|
||||
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
|
||||
cornerEl={<FilterBar />}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
customData={chartData.containerData}
|
||||
dataPoints={dataPoints}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={(item) => {
|
||||
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
||||
return `${decimalString(value)} ${unit}`
|
||||
}}
|
||||
domain={pinnedAxisDomain()}
|
||||
showTotal={true}
|
||||
reverseStackOrder={true}
|
||||
filter={filter}
|
||||
truncate={true}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SwapChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
systemStats,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
systemStats: SystemStatsRecord[]
|
||||
}) {
|
||||
// const userSettings = useStore($userSettings)
|
||||
|
||||
const hasSwapData = (systemStats.at(-1)?.stats.su ?? 0) > 0
|
||||
if (!hasSwapData) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Swap Usage`} description={t`Swap space used by the system`}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||
contentFormatter={({ value }) => {
|
||||
// mem values are supplied as GB
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
tickFormatter={(value) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Used`,
|
||||
dataKey: ({ stats }) => stats?.su,
|
||||
color: 2,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
></AreaChartDefault>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMemo } from "react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { useContainerDataPoints } from "@/components/charts/hooks"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartConfig } from "@/components/ui/chart"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import NetworkSheet from "../network-sheet"
|
||||
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
|
||||
import { dockerOrPodman } from "../chart-data"
|
||||
|
||||
export function BandwidthChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
showMax,
|
||||
isLongerChart,
|
||||
maxValues,
|
||||
systemStats,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
showMax: boolean
|
||||
isLongerChart: boolean
|
||||
maxValues: boolean
|
||||
systemStats: SystemStatsRecord[]
|
||||
}) {
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const userSettings = $userSettings.get()
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Bandwidth`}
|
||||
cornerEl={
|
||||
<div className="flex gap-2">
|
||||
{maxValSelect}
|
||||
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
||||
</div>
|
||||
}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Sent`,
|
||||
dataKey(data: SystemStatsRecord) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 5,
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
label: t`Received`,
|
||||
dataKey(data: SystemStatsRecord) {
|
||||
if (showMax) {
|
||||
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 2,
|
||||
opacity: 0.2,
|
||||
},
|
||||
]
|
||||
// try to place the lesser number in front for better visibility
|
||||
.sort(() => (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={(data) => {
|
||||
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
showTotal={true}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContainerNetworkChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
isPodman,
|
||||
networkConfig,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
isPodman: boolean
|
||||
networkConfig: ChartConfig
|
||||
}) {
|
||||
const userSettings = $userSettings.get()
|
||||
const { filter, dataPoints, filteredKeys } = useContainerDataPoints(networkConfig, (key, data) => {
|
||||
const payload = data[key]
|
||||
if (!payload) return null
|
||||
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
|
||||
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
|
||||
return sent + recv
|
||||
})
|
||||
|
||||
const contentFormatter = useMemo(() => {
|
||||
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
|
||||
if (record?.b?.length && record.b.length >= 2) {
|
||||
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
|
||||
}
|
||||
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
|
||||
}
|
||||
const formatRxTx = (recv: number, sent: number) => {
|
||||
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
|
||||
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
|
||||
return (
|
||||
<span className="flex">
|
||||
{decimalString(receivedValue)} {receivedUnit}
|
||||
<span className="opacity-70 ms-0.5"> rx </span>
|
||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||
{decimalString(sentValue)} {sentUnit}
|
||||
<span className="opacity-70 ms-0.5"> tx</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item
|
||||
return (item: any, key: string) => {
|
||||
try {
|
||||
if (key === "__total__") {
|
||||
let totalSent = 0
|
||||
let totalRecv = 0
|
||||
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
|
||||
for (const [containerKey, value] of Object.entries(payloadData)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
if (filteredKeys.has(containerKey)) continue
|
||||
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
|
||||
totalSent += sent
|
||||
totalRecv += recv
|
||||
}
|
||||
return formatRxTx(totalRecv, totalSent)
|
||||
}
|
||||
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
|
||||
return formatRxTx(recv, sent)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}, [filteredKeys, userSettings.unitNet])
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
|
||||
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
|
||||
cornerEl={<FilterBar />}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
customData={chartData.containerData}
|
||||
dataPoints={dataPoints}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={contentFormatter}
|
||||
domain={pinnedAxisDomain()}
|
||||
showTotal={true}
|
||||
reverseStackOrder={true}
|
||||
filter={filter}
|
||||
truncate={true}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import { $temperatureFilter, $userSettings } from "@/lib/stores"
|
||||
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { useRef, useMemo, useState, useEffect } from "react"
|
||||
|
||||
export function BatteryChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
maxValues: boolean
|
||||
}) {
|
||||
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
|
||||
|
||||
if (!showBatteryChart) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Battery`}
|
||||
description={`${t({
|
||||
message: "Current state",
|
||||
comment: "Context: Battery state",
|
||||
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Charge`,
|
||||
dataKey: ({ stats }) => stats?.bat?.[0],
|
||||
color: 1,
|
||||
opacity: 0.35,
|
||||
},
|
||||
]}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(val) => `${val}%`}
|
||||
contentFormatter={({ value }) => `${value}%`}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function TemperatureChart({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
setPageBottomExtraMargin,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
setPageBottomExtraMargin?: (margin: number) => void
|
||||
}) {
|
||||
const showTempChart = chartData.systemStats.at(-1)?.stats.t
|
||||
|
||||
const filter = useStore($temperatureFilter)
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
const statsRef = useRef(chartData.systemStats)
|
||||
statsRef.current = chartData.systemStats
|
||||
|
||||
// Derive sensor names key from latest data point
|
||||
let sensorNamesKey = ""
|
||||
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
|
||||
const t = chartData.systemStats[i].stats?.t
|
||||
if (t) {
|
||||
sensorNamesKey = Object.keys(t).sort().join("\0")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only recompute colors and dataKey functions when sensor names change
|
||||
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
|
||||
const stats = statsRef.current
|
||||
const tempSums = {} as Record<string, number>
|
||||
for (const data of stats) {
|
||||
const t = data.stats?.t
|
||||
if (!t) continue
|
||||
for (const key of Object.keys(t)) {
|
||||
tempSums[key] = (tempSums[key] ?? 0) + t[key]
|
||||
}
|
||||
}
|
||||
const sorted = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
||||
const colorMap = {} as Record<string, string>
|
||||
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const key = sorted[i]
|
||||
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
|
||||
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.t?.[key]
|
||||
}
|
||||
return { colorMap, dataKeys, sortedKeys: sorted }
|
||||
}, [sensorNamesKey])
|
||||
|
||||
const dataPoints = useMemo(() => {
|
||||
return sortedKeys.map((key) => {
|
||||
const filterTerms = filter
|
||||
? filter
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((term) => term.length > 0)
|
||||
: []
|
||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
||||
const strokeOpacity = filtered ? 0.1 : 1
|
||||
return {
|
||||
label: key,
|
||||
dataKey: dataKeys[key],
|
||||
color: colorMap[key],
|
||||
opacity: strokeOpacity,
|
||||
}
|
||||
})
|
||||
}, [sortedKeys, filter, dataKeys, colorMap])
|
||||
|
||||
// test with lots of data points
|
||||
// const totalPoints = 50
|
||||
// if (dataPoints.length > 0 && dataPoints.length < totalPoints) {
|
||||
// let i = 0
|
||||
// while (dataPoints.length < totalPoints) {
|
||||
// dataPoints.push({
|
||||
// label: `Test ${++i}`,
|
||||
// dataKey: () => 0,
|
||||
// color: "red",
|
||||
// opacity: 1,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const [addMargin, setAddMargin] = useState(false)
|
||||
const marginPx = (dataPoints.length - 13) * 18
|
||||
|
||||
useEffect(() => {
|
||||
if (setPageBottomExtraMargin && dataPoints.length > 13 && chartRef.current) {
|
||||
const checkPosition = () => {
|
||||
if (!chartRef.current) return
|
||||
const rect = chartRef.current.getBoundingClientRect()
|
||||
const actualScrollHeight = addMargin
|
||||
? document.documentElement.scrollHeight - marginPx
|
||||
: document.documentElement.scrollHeight
|
||||
const distanceToBottom = actualScrollHeight - (rect.bottom + window.scrollY)
|
||||
|
||||
if (distanceToBottom < 250) {
|
||||
setAddMargin(true)
|
||||
setPageBottomExtraMargin(marginPx)
|
||||
} else {
|
||||
setAddMargin(false)
|
||||
setPageBottomExtraMargin(0)
|
||||
}
|
||||
}
|
||||
checkPosition()
|
||||
const timer = setTimeout(checkPosition, 500)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
} else if (addMargin) {
|
||||
setAddMargin(false)
|
||||
if (setPageBottomExtraMargin) setPageBottomExtraMargin(0)
|
||||
}
|
||||
}, [dataPoints.length, addMargin, marginPx, setPageBottomExtraMargin])
|
||||
|
||||
if (!showTempChart) {
|
||||
return null
|
||||
}
|
||||
|
||||
const legend = dataPoints.length < 12
|
||||
|
||||
return (
|
||||
<div ref={chartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Temperature`}
|
||||
description={t`Temperatures of system sensors`}
|
||||
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||
legend={legend}
|
||||
>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
domain={["auto", "auto"]}
|
||||
legend={legend}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||
return `${toFixedFloat(value, 2)} ${unit}`
|
||||
}}
|
||||
contentFormatter={(item) => {
|
||||
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
|
||||
return `${decimalString(value)} ${unit}`
|
||||
}}
|
||||
dataPoints={dataPoints}
|
||||
></LineChartDefault>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { DialogTitle } from "@/components/ui/dialog"
|
||||
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard } from "./chart-card"
|
||||
|
||||
const minAgentVersion = parseSemVer("0.15.3")
|
||||
|
||||
export default memo(function CpuCoresSheet({
|
||||
chartData,
|
||||
dataEmpty,
|
||||
grid,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
dataEmpty: boolean
|
||||
grid: boolean
|
||||
maxValues: boolean
|
||||
}) {
|
||||
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
|
||||
const hasOpened = useRef(false)
|
||||
|
||||
const supportsBreakdown = compareSemVer(chartData.agentVersion, minAgentVersion) >= 0
|
||||
|
||||
if (!supportsBreakdown) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (cpuCoresOpen && !hasOpened.current) {
|
||||
hasOpened.current = true
|
||||
}
|
||||
|
||||
// Latest stats snapshot
|
||||
const latest = chartData.systemStats.at(-1)?.stats
|
||||
const cpus = latest?.cpus ?? []
|
||||
const numCores = cpus.length
|
||||
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
|
||||
|
||||
// make sure all individual core data points have the same y axis domain to make relative comparison easier
|
||||
let highestCpuCorePct = 1
|
||||
if (hasOpened.current) {
|
||||
for (let i = 0; i < numCores; i++) {
|
||||
for (let j = 0; j < chartData.systemStats.length; j++) {
|
||||
const pct = chartData.systemStats[j].stats?.cpus?.[i] ?? 0
|
||||
if (pct > highestCpuCorePct) {
|
||||
highestCpuCorePct = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const breakdownDataPoints = [
|
||||
{
|
||||
label: "System",
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
|
||||
color: 3,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
|
||||
color: 1,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
{
|
||||
label: "IOWait",
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
|
||||
color: 4,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
{
|
||||
label: "Steal",
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
|
||||
color: 5,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
{
|
||||
label: "Idle",
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
|
||||
color: 2,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
{
|
||||
label: t`Other`,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
|
||||
return total > 0 ? 100 - total : null
|
||||
},
|
||||
color: `hsl(80, 65%, 52%)`,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
},
|
||||
] as DataPoint[]
|
||||
|
||||
return (
|
||||
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
|
||||
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
title={t`View more`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{hasOpened.current && (
|
||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||
{hasBreakdown && (
|
||||
<ChartCard
|
||||
key="cpu-breakdown"
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`CPU Time Breakdown`}
|
||||
description={t`Percentage of time spent in each state`}
|
||||
legend={true}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
legend={true}
|
||||
dataPoints={breakdownDataPoints}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
reverseStackOrder={true}
|
||||
itemSorter={() => 1}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{numCores > 0 && (
|
||||
<ChartCard
|
||||
key="cpu-cores-all"
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`CPU Cores`}
|
||||
legend={numCores < 10}
|
||||
description={t`Per-core average utilization`}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
hideYAxis={true}
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
legend={numCores < 10}
|
||||
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
|
||||
label: `CPU ${i}`,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
|
||||
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
opacity: 0.35,
|
||||
stackId: "a",
|
||||
}))}
|
||||
tickFormatter={(val) => `${val}%`}
|
||||
contentFormatter={({ value }) => `${value}%`}
|
||||
reverseStackOrder={true}
|
||||
itemSorter={() => 1}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{Array.from({ length: numCores }).map((_, i) => (
|
||||
<ChartCard
|
||||
key={`cpu-core-${i}`}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`CPU ${i}`}
|
||||
description={t`Per-core average utilization`}
|
||||
legend={false}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
domain={[0, highestCpuCorePct]}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
|
||||
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
|
||||
opacity: 0.35,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => `${val}%`}
|
||||
contentFormatter={({ value }) => `${value}%`}
|
||||
/>
|
||||
</ChartCard>
|
||||
))}
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { DialogTitle } from "@/components/ui/dialog"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { ChartCard, SelectAvgMax } from "@/components/routes/system/chart-card"
|
||||
import type { SystemData } from "@/components/routes/system/use-system-data"
|
||||
import { diskDataFns, DiskUtilizationChart } from "./charts/disk-charts"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
|
||||
export default memo(function DiskIOSheet({
|
||||
systemData,
|
||||
extraFsName,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
systemData: SystemData
|
||||
extraFsName?: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
const { chartData, grid, dataEmpty, showMax, maxValues, isLongerChart } = systemData
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const hasOpened = useRef(false)
|
||||
|
||||
if (sheetOpen && !hasOpened.current) {
|
||||
hasOpened.current = true
|
||||
}
|
||||
|
||||
// throughput functions, with extra fs variants if needed
|
||||
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
|
||||
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
|
||||
if (extraFsName) {
|
||||
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
|
||||
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
|
||||
}
|
||||
|
||||
// read and write time functions, with extra fs variants if needed
|
||||
let readTimeFn = showMax ? diskDataFns.readTimeMax : diskDataFns.readTime
|
||||
let writeTimeFn = showMax ? diskDataFns.writeTimeMax : diskDataFns.writeTime
|
||||
if (extraFsName) {
|
||||
readTimeFn = showMax ? diskDataFns.extraReadTimeMax(extraFsName) : diskDataFns.extraReadTime(extraFsName)
|
||||
writeTimeFn = showMax ? diskDataFns.extraWriteTimeMax(extraFsName) : diskDataFns.extraWriteTime(extraFsName)
|
||||
}
|
||||
|
||||
// I/O await functions, with extra fs variants if needed
|
||||
let rAwaitFn = showMax ? diskDataFns.rAwaitMax : diskDataFns.rAwait
|
||||
let wAwaitFn = showMax ? diskDataFns.wAwaitMax : diskDataFns.wAwait
|
||||
if (extraFsName) {
|
||||
rAwaitFn = showMax ? diskDataFns.extraRAwaitMax(extraFsName) : diskDataFns.extraRAwait(extraFsName)
|
||||
wAwaitFn = showMax ? diskDataFns.extraWAwaitMax(extraFsName) : diskDataFns.extraWAwait(extraFsName)
|
||||
}
|
||||
|
||||
// weighted I/O function, with extra fs variant if needed
|
||||
let weightedIOFn = showMax ? diskDataFns.weightedIOMax : diskDataFns.weightedIO
|
||||
if (extraFsName) {
|
||||
weightedIOFn = showMax ? diskDataFns.extraWeightedIOMax(extraFsName) : diskDataFns.extraWeightedIO(extraFsName)
|
||||
}
|
||||
|
||||
// check for availability of I/O metrics
|
||||
let hasUtilization = false
|
||||
let hasAwait = false
|
||||
let hasWeightedIO = false
|
||||
for (const record of chartData.systemStats ?? []) {
|
||||
const dios = record.stats?.dios
|
||||
if ((dios?.at(2) ?? 0) > 0) hasUtilization = true
|
||||
if ((dios?.at(3) ?? 0) > 0) hasAwait = true
|
||||
if ((dios?.at(5) ?? 0) > 0) hasWeightedIO = true
|
||||
if (hasUtilization && hasAwait && hasWeightedIO) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
|
||||
const chartProps = { syncId: "io" }
|
||||
|
||||
const queueDepthTranslation = t({ message: "Queue Depth", context: "Disk I/O average queue depth" })
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
title={t`View more`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{hasOpened.current && (
|
||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||
|
||||
<ChartCard
|
||||
className="min-h-auto"
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={title}
|
||||
description={description}
|
||||
cornerEl={maxValSelect}
|
||||
// legend={true}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
showTotal={true}
|
||||
domain={pinnedAxisDomain()}
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
reverseStackOrder={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: writeFn,
|
||||
color: 3,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: readFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 1,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasUtilization && <DiskUtilizationChart systemData={systemData} extraFsName={extraFsName} />}
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({ message: "I/O Time", context: "Disk I/O total time spent on read/write" })}
|
||||
description={t({
|
||||
message: "Total time spent on read/write (can exceed 100%)",
|
||||
context: "Disk I/O",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
showTotal={true}
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
reverseStackOrder={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: writeTimeFn,
|
||||
color: 3,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: readTimeFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasWeightedIO && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={queueDepthTranslation}
|
||||
description={t`Average number of I/O operations waiting to be serviced`}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}`}
|
||||
contentFormatter={({ value }) => decimalString(value, value < 10 ? 3 : 2)}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
dataPoints={[
|
||||
{
|
||||
label: queueDepthTranslation,
|
||||
dataKey: weightedIOFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{hasAwait && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({ message: "I/O Await", context: "Disk I/O average operation time (iostat await)" })}
|
||||
description={t({
|
||||
message: "Average queue to completion time per operation",
|
||||
context: "Disk I/O average operation time (iostat await)",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
// legend={true}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)} ms`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)} ms`}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: wAwaitFn,
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: rAwaitFn,
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { plural } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import {
|
||||
AppleIcon,
|
||||
ChevronRightSquareIcon,
|
||||
ClockArrowUp,
|
||||
CpuIcon,
|
||||
GlobeIcon,
|
||||
MemoryStickIcon,
|
||||
MonitorIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
|
||||
import { cn, formatBytes, getHostDisplayValue, secondsToUptimeString, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
|
||||
|
||||
export default function InfoBar({
|
||||
system,
|
||||
chartData,
|
||||
grid,
|
||||
setGrid,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
details,
|
||||
}: {
|
||||
system: SystemRecord
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
setGrid: (grid: boolean) => void
|
||||
displayMode: "default" | "tabs"
|
||||
setDisplayMode: (mode: "default" | "tabs") => void
|
||||
details: SystemDetailsRecord | null
|
||||
}) {
|
||||
const { t } = useLingui()
|
||||
|
||||
// values for system info bar - use details with fallback to system.info
|
||||
const systemInfo = useMemo(() => {
|
||||
if (!system.info) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Use details if available, otherwise fall back to system.info
|
||||
const hostname = details?.hostname ?? system.info.h
|
||||
const kernel = details?.kernel ?? system.info.k
|
||||
const cores = details?.cores ?? system.info.c
|
||||
const threads = details?.threads ?? system.info.t ?? 0
|
||||
const cpuModel = details?.cpu ?? system.info.m
|
||||
const os = details?.os ?? system.info.os ?? Os.Linux
|
||||
const osName = details?.os_name
|
||||
const arch = details?.arch
|
||||
const memory = details?.memory
|
||||
|
||||
const osInfo = {
|
||||
[Os.Linux]: {
|
||||
Icon: TuxIcon,
|
||||
// show kernel in tooltip if os name is available, otherwise show the kernel
|
||||
value: osName || kernel,
|
||||
label: osName ? kernel : undefined,
|
||||
},
|
||||
[Os.Darwin]: {
|
||||
Icon: AppleIcon,
|
||||
value: osName || `macOS ${kernel}`,
|
||||
},
|
||||
[Os.Windows]: {
|
||||
Icon: WindowsIcon,
|
||||
value: osName || kernel,
|
||||
label: osName ? kernel : undefined,
|
||||
},
|
||||
[Os.FreeBSD]: {
|
||||
Icon: FreeBsdIcon,
|
||||
value: osName || kernel,
|
||||
label: osName ? kernel : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const info = [
|
||||
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||
{
|
||||
value: hostname,
|
||||
Icon: MonitorIcon,
|
||||
label: "Hostname",
|
||||
// hide if hostname is same as host or name
|
||||
hide: hostname === system.host || hostname === system.name,
|
||||
},
|
||||
{ value: secondsToUptimeString(system.info.u), Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
|
||||
osInfo[os],
|
||||
{
|
||||
value: cpuModel,
|
||||
Icon: CpuIcon,
|
||||
hide: !cpuModel,
|
||||
label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`,
|
||||
},
|
||||
] as {
|
||||
value: string | number | undefined
|
||||
label?: string
|
||||
Icon: React.ElementType
|
||||
hide?: boolean
|
||||
}[]
|
||||
|
||||
if (memory) {
|
||||
const memValue = formatBytes(memory, false, undefined, false)
|
||||
info.push({
|
||||
value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`,
|
||||
Icon: MemoryStickIcon,
|
||||
hide: !memory,
|
||||
label: t`Memory`,
|
||||
})
|
||||
}
|
||||
|
||||
return info
|
||||
}, [system, details, t])
|
||||
|
||||
let translatedStatus: string = system.status
|
||||
if (system.status === SystemStatus.Up) {
|
||||
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
|
||||
} else if (system.status === SystemStatus.Down) {
|
||||
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="grid xl:flex xl:gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl sm:text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||
<div className="flex xl:flex-wrap items-center py-4 xl:p-0 -mt-3 xl:mt-1 gap-3 text-sm text-nowrap opacity-90 overflow-x-auto scrollbar-hide -mx-4 px-4 xl:mx-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="capitalize flex gap-2 items-center">
|
||||
<span className={cn("relative flex h-3 w-3")}>
|
||||
{system.status === SystemStatus.Up && (
|
||||
<span
|
||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
style={{ animationDuration: "1.5s" }}
|
||||
></span>
|
||||
)}
|
||||
<span
|
||||
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
||||
"bg-green-500": system.status === SystemStatus.Up,
|
||||
"bg-red-500": system.status === SystemStatus.Down,
|
||||
"bg-primary/40": system.status === SystemStatus.Paused,
|
||||
"bg-yellow-500": system.status === SystemStatus.Pending,
|
||||
})}
|
||||
></span>
|
||||
</span>
|
||||
{translatedStatus}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{system.info.ct && (
|
||||
<TooltipContent>
|
||||
<div className="flex gap-1 items-center">
|
||||
{system.info.ct === ConnectionType.WebSocket ? (
|
||||
<WebSocketIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
|
||||
)}
|
||||
{connectionTypeLabels[system.info.ct as ConnectionType]}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{systemInfo.map(({ value, label, Icon, hide }) => {
|
||||
if (hide || !value) {
|
||||
return null
|
||||
}
|
||||
const content = (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<Icon className="h-4 w-4" /> {value}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div key={value} className="contents">
|
||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
||||
{label ? (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t`Settings`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden xl:flex p-0 text-primary"
|
||||
>
|
||||
<Settings2Icon className="size-4 opacity-90" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuLabel className="px-3.5">
|
||||
<Trans context="Layout display options">Display</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={displayMode}
|
||||
onValueChange={(v) => setDisplayMode(v as "default" | "tabs")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="default" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans context="Default system layout option">Default</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="tabs" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans context="Tabs system layout option">Tabs</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-3.5">
|
||||
<Trans>Chart width</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={grid ? "grid" : "full"}
|
||||
onValueChange={(v) => setGrid(v === "grid")}
|
||||
>
|
||||
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Grid</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="full" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Full</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { lazy } from "react"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||
|
||||
export function LazyContainersTable({ systemId }: { systemId: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <ContainersTable systemId={systemId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SmartTable = lazy(() => import("./smart-table"))
|
||||
|
||||
export function LazySmartTable({ systemId }: { systemId: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <SmartTable systemId={systemId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SystemdTable = lazy(() => import("../../systemd-table/systemd-table"))
|
||||
|
||||
export function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <SystemdTable systemId={systemId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||
import { useNetworkInterfaces } from "@/components/charts/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { DialogTitle } from "@/components/ui/dialog"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData } from "@/types"
|
||||
import { ChartCard } from "./chart-card"
|
||||
|
||||
export default memo(function NetworkSheet({
|
||||
chartData,
|
||||
dataEmpty,
|
||||
grid,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
dataEmpty: boolean
|
||||
grid: boolean
|
||||
maxValues: boolean
|
||||
}) {
|
||||
const [netInterfacesOpen, setNetInterfacesOpen] = useState(false)
|
||||
const userSettings = useStore($userSettings)
|
||||
const netInterfaces = useNetworkInterfaces(chartData.systemStats.at(-1)?.stats?.ni ?? {})
|
||||
const showNetLegend = netInterfaces.length > 0 && netInterfaces.length < 15
|
||||
const hasOpened = useRef(false)
|
||||
|
||||
if (netInterfacesOpen && !hasOpened.current) {
|
||||
hasOpened.current = true
|
||||
}
|
||||
|
||||
if (!netInterfaces.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={netInterfacesOpen} onOpenChange={setNetInterfacesOpen}>
|
||||
<DialogTitle className="sr-only">{t`Network traffic of public interfaces`}</DialogTitle>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
title={t`View more`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{hasOpened.current && (
|
||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Download`}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
legend={showNetLegend}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
dataPoints={netInterfaces.data(1)}
|
||||
legend={showNetLegend}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Upload`}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
legend={showNetLegend}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={maxValues}
|
||||
itemSorter={(a, b) => b.value - a.value}
|
||||
legend={showNetLegend}
|
||||
dataPoints={netInterfaces.data(0)}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitNet, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Cumulative Download`}
|
||||
description={t`Total data received for each interface`}
|
||||
legend={showNetLegend}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
legend={showNetLegend}
|
||||
dataPoints={netInterfaces.data(3)}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Cumulative Upload`}
|
||||
description={t`Total data sent for each interface`}
|
||||
legend={showNetLegend}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
legend={showNetLegend}
|
||||
dataPoints={netInterfaces.data(2)}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, userSettings.unitNet, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, userSettings.unitNet, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,849 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type Column,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Clock,
|
||||
HardDrive,
|
||||
BinaryIcon,
|
||||
RotateCwIcon,
|
||||
LoaderCircleIcon,
|
||||
CheckCircle2Icon,
|
||||
XCircleIcon,
|
||||
ArrowLeftRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
RefreshCwIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||
import {
|
||||
formatBytes,
|
||||
toFixedFloat,
|
||||
formatTemperature,
|
||||
cn,
|
||||
getVisualStringWidth,
|
||||
secondsToString,
|
||||
hourWithSeconds,
|
||||
formatShortDate,
|
||||
} from "@/lib/utils"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { ThermometerIcon } from "@/components/ui/icons"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
// Column definition for S.M.A.R.T. attributes table
|
||||
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.n,
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.rs || row.rv?.toString(),
|
||||
header: "Value",
|
||||
},
|
||||
{
|
||||
accessorKey: "v",
|
||||
header: "Normalized",
|
||||
},
|
||||
{
|
||||
accessorKey: "w",
|
||||
header: "Worst",
|
||||
},
|
||||
{
|
||||
accessorKey: "t",
|
||||
header: "Threshold",
|
||||
},
|
||||
{
|
||||
// accessorFn: (row) => row.wf,
|
||||
accessorKey: "wf",
|
||||
header: "Failing",
|
||||
},
|
||||
]
|
||||
|
||||
// Function to format capacity display
|
||||
function formatCapacity(bytes: number): string {
|
||||
const { value, unit } = formatBytes(bytes)
|
||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||
}
|
||||
|
||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||
|
||||
export const createColumns = (
|
||||
longestName: number,
|
||||
longestModel: number,
|
||||
longestDevice: number
|
||||
): ColumnDef<SmartDeviceRecord>[] => [
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
return systemNameA.localeCompare(systemNameB)
|
||||
},
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return (
|
||||
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{allSystems[getValue() as string]?.name ?? ""}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div
|
||||
className="font-medium max-w-40 truncate ms-1"
|
||||
title={getValue() as string}
|
||||
style={{ width: `${longestDevice / 1.05}ch` }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<div
|
||||
className="max-w-48 truncate ms-1"
|
||||
title={getValue() as string}
|
||||
style={{ width: `${longestModel / 1.05}ch` }}
|
||||
>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "capacity",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1">{formatCapacity(getValue() as number)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue() as string
|
||||
return (
|
||||
<Badge className="ms-1" variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<Badge variant="outline" className="ms-1 uppercase">
|
||||
{getValue() as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const hours = getValue() as number | undefined
|
||||
if (hours == null) {
|
||||
return <div className="text-sm text-muted-foreground ms-1">N/A</div>
|
||||
}
|
||||
const seconds = hours * 3600
|
||||
return (
|
||||
<div className="text-sm ms-1">
|
||||
<div>{secondsToString(seconds, "hour")}</div>
|
||||
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "cycles",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => (
|
||||
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const cycles = getValue() as number | undefined
|
||||
if (cycles == null) {
|
||||
return <div className="text-muted-foreground ms-1">N/A</div>
|
||||
}
|
||||
return <span className="ms-1">{cycles.toLocaleString()}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "temp",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const temp = getValue() as number | null | undefined
|
||||
if (!temp) {
|
||||
return <div className="text-muted-foreground ms-1">N/A</div>
|
||||
}
|
||||
const { value, unit } = formatTemperature(temp)
|
||||
return <span className="ms-1">{`${value} ${unit}`}</span>
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "serial",
|
||||
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
|
||||
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "firmware",
|
||||
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
|
||||
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||
// },
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={Clock} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as string
|
||||
// if today, use hourWithSeconds, otherwise use formatShortDate
|
||||
const formatter =
|
||||
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
|
||||
return <span className="ms-1 tabular-nums">{formatter(timestamp)}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<SmartDeviceRecord>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
||||
const [activeDiskId, setActiveDiskId] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
const allSystems = useStore($allSystemsById)
|
||||
|
||||
// duplicate the devices to test with more rows
|
||||
// if (
|
||||
// smartDevices?.length &&
|
||||
// smartDevices.length < 50 &&
|
||||
// typeof window !== "undefined" &&
|
||||
// window.location.hostname === "localhost"
|
||||
// ) {
|
||||
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
|
||||
// }
|
||||
|
||||
// Calculate the right width for the columns based on the longest strings among the displayed devices
|
||||
const { longestName, longestModel, longestDevice } = useMemo(() => {
|
||||
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
|
||||
if (!smartDevices || Object.keys(allSystems).length === 0) {
|
||||
return result
|
||||
}
|
||||
const seenSystems = new Set<string>()
|
||||
for (const device of smartDevices) {
|
||||
if (!systemId && !seenSystems.has(device.system)) {
|
||||
seenSystems.add(device.system)
|
||||
const name = allSystems[device.system]?.name ?? ""
|
||||
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
|
||||
}
|
||||
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
|
||||
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
|
||||
}
|
||||
return result
|
||||
}, [smartDevices, systemId, allSystems])
|
||||
|
||||
const openSheet = (disk: SmartDeviceRecord) => {
|
||||
setActiveDiskId(disk.id)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
// Fetch smart devices
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||
.getFullList({
|
||||
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
||||
fields: SMART_DEVICE_FIELDS,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(setSmartDevices)
|
||||
.catch((err) => {
|
||||
if (!err.isAbort) {
|
||||
setSmartDevices([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [systemId])
|
||||
|
||||
// Subscribe to updates
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = systemId
|
||||
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||
: { fields: SMART_DEVICE_FIELDS }
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
const record = event.record as SmartDeviceRecord
|
||||
setSmartDevices((currentDevices) => {
|
||||
const devices = currentDevices ?? []
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
|
||||
if (event.action === "delete") {
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
if (!matchesSystemScope) {
|
||||
// Record moved out of scope; ensure it disappears locally.
|
||||
return devices.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||
if (existingIndex === -1) {
|
||||
return [record, ...devices]
|
||||
}
|
||||
|
||||
const next = [...devices]
|
||||
next[existingIndex] = record
|
||||
return next
|
||||
})
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to SMART device updates:", error)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [systemId])
|
||||
|
||||
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
|
||||
if (!disk.system) return
|
||||
setRowActionState({ type: "refresh", id: disk.id })
|
||||
try {
|
||||
await pb.send("/api/beszel/smart/refresh", {
|
||||
method: "POST",
|
||||
query: { system: disk.system },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh SMART device:", error)
|
||||
} finally {
|
||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
|
||||
setRowActionState({ type: "delete", id: disk.id })
|
||||
try {
|
||||
await pb.collection("smart_devices").delete(disk.id)
|
||||
// setSmartDevices((current) => current?.filter((device) => device.id !== disk.id))
|
||||
} catch (error) {
|
||||
console.error("Failed to delete SMART device:", error)
|
||||
} finally {
|
||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
|
||||
() => ({
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => (
|
||||
<span className="sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const disk = row.original
|
||||
const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh"
|
||||
const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete"
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleRowRefresh(disk)
|
||||
}}
|
||||
disabled={isRowRefreshing || isRowDeleting}
|
||||
>
|
||||
<RefreshCwIcon className={cn("me-2.5 size-4", isRowRefreshing && "animate-spin")} />
|
||||
<Trans>Refresh</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDeleteDevice(disk)
|
||||
}}
|
||||
disabled={isRowDeleting}
|
||||
>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
[handleRowRefresh, handleDeleteDevice, rowActionState]
|
||||
)
|
||||
|
||||
// Filter columns based on whether systemId is provided
|
||||
const tableColumns = useMemo(() => {
|
||||
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
|
||||
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||
|
||||
const table = useReactTable({
|
||||
data: smartDevices || ([] as SmartDeviceRecord[]),
|
||||
columns: tableColumns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const disk = row.original
|
||||
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
||||
const device = disk.name ?? ""
|
||||
const model = disk.model ?? ""
|
||||
const status = disk.state ?? ""
|
||||
const type = disk.type ?? ""
|
||||
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
// Hide the table on system pages if there's no data, but always show on global page
|
||||
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">S.M.A.R.T.</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a device to view more information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="px-4 w-full max-w-full md:w-64"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<SmartDevicesTable
|
||||
table={table}
|
||||
rows={rows}
|
||||
colLength={tableColumns.length}
|
||||
data={smartDevices}
|
||||
openSheet={openSheet}
|
||||
/>
|
||||
</Card>
|
||||
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SmartDevicesTable = memo(function SmartDevicesTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
openSheet,
|
||||
}: {
|
||||
table: TableType<SmartDeviceRecord>
|
||||
rows: Row<SmartDeviceRecord>[]
|
||||
colLength: number
|
||||
data: SmartDeviceRecord[] | undefined
|
||||
openSheet: (disk: SmartDeviceRecord) => void
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 65,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto rounded-md border",
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="w-full text-sm text-nowrap">
|
||||
<SmartTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="px-2">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const SmartDeviceTableRow = memo(function SmartDeviceTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<SmartDeviceRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (disk: SmartDeviceRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="md:ps-5 py-0"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
||||
function DiskSheet({
|
||||
diskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
diskId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [disk, setDisk] = useState<SmartDeviceRecord | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Fetch full device record (including attributes) when sheet opens
|
||||
useEffect(() => {
|
||||
if (!diskId) {
|
||||
setDisk(null)
|
||||
return
|
||||
}
|
||||
// Only fetch when opening, not when closing (keeps data visible during close animation)
|
||||
if (!open) return
|
||||
setIsLoading(true)
|
||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||
.getOne(diskId)
|
||||
.then(setDisk)
|
||||
.catch(() => setDisk(null))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [open, diskId])
|
||||
|
||||
const smartAttributes = disk?.attributes || []
|
||||
|
||||
// Find all attributes where when failed is not empty
|
||||
const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "")
|
||||
|
||||
// Filter columns to only show those that have values in at least one row
|
||||
const visibleColumns = useMemo(() => {
|
||||
return smartColumns.filter((column) => {
|
||||
const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined
|
||||
if (!accessorKey) {
|
||||
return true
|
||||
}
|
||||
// Check if any row has a non-empty value for this column
|
||||
return smartAttributes.some((attr) => {
|
||||
return attr[accessorKey] !== undefined
|
||||
})
|
||||
})
|
||||
}, [smartAttributes])
|
||||
|
||||
const table = useReactTable({
|
||||
data: smartAttributes,
|
||||
columns: visibleColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const unknown = "Unknown"
|
||||
const deviceName = disk?.name || unknown
|
||||
const model = disk?.model || unknown
|
||||
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
|
||||
const serialNumber = disk?.serial
|
||||
const firmwareVersion = disk?.firmware
|
||||
const status = disk?.state || unknown
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-220 gap-0">
|
||||
<SheetHeader className="mb-0 border-b">
|
||||
<SheetTitle>
|
||||
<Trans>S.M.A.R.T. Details</Trans> - {deviceName}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{model}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{capacity}
|
||||
{serialNumber && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{serialNumber}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Serial Number</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{firmwareVersion && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{firmwareVersion}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Firmware</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden p-4 flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Alert className="pb-3 shrink-0">
|
||||
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
|
||||
<AlertTitle>
|
||||
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
|
||||
</AlertTitle>
|
||||
{failedAttributes.length > 0 && (
|
||||
<AlertDescription>
|
||||
<Trans>Failed Attributes:</Trans> {failedAttributes.map((attr) => attr.n).join(", ")}
|
||||
</AlertDescription>
|
||||
)}
|
||||
</Alert>
|
||||
{smartAttributes.length > 0 ? (
|
||||
<div className="rounded-md border min-h-0 flex flex-col">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
// Check if the attribute is failed
|
||||
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ""
|
||||
|
||||
return (
|
||||
<TableRow key={row.id} className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { subscribeKeys } from "nanostores"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useContainerChartConfigs } from "@/components/charts/hooks"
|
||||
import { pb } from "@/lib/api"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import {
|
||||
$allSystemsById,
|
||||
$allSystemsByName,
|
||||
$chartTime,
|
||||
$containerFilter,
|
||||
$direction,
|
||||
$maxValues,
|
||||
$systems,
|
||||
$userSettings,
|
||||
} from "@/lib/stores"
|
||||
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
|
||||
import type {
|
||||
ChartData,
|
||||
ContainerStatsRecord,
|
||||
SystemDetailsRecord,
|
||||
SystemInfo,
|
||||
SystemRecord,
|
||||
SystemStats,
|
||||
SystemStatsRecord,
|
||||
} from "@/types"
|
||||
import { $router, navigate } from "../../router"
|
||||
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||
|
||||
export type SystemData = ReturnType<typeof useSystemData>
|
||||
|
||||
export function useSystemData(id: string) {
|
||||
const direction = useStore($direction)
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
const maxValues = useStore($maxValues)
|
||||
const [grid, setGrid] = useBrowserStorage("grid", true)
|
||||
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
|
||||
const [activeTab, setActiveTabRaw] = useState("core")
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
|
||||
const tabsRef = useRef<string[]>(["core", "disk"])
|
||||
|
||||
function setActiveTab(tab: string) {
|
||||
setActiveTabRaw(tab)
|
||||
setMountedTabs((prev) => (prev.has(tab) ? prev : new Set([...prev, tab])))
|
||||
}
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||
const persistChartTime = useRef(false)
|
||||
const statsRequestId = useRef(0)
|
||||
const [chartLoading, setChartLoading] = useState(true)
|
||||
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!persistChartTime.current) {
|
||||
$chartTime.set($userSettings.get().chartTime)
|
||||
}
|
||||
persistChartTime.current = false
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setDetails({} as SystemDetailsRecord)
|
||||
$containerFilter.set("")
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// find matching system and update when it changes
|
||||
useEffect(() => {
|
||||
if (!systems.length) {
|
||||
return
|
||||
}
|
||||
// allow old system-name slug to work
|
||||
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
|
||||
return subscribeKeys(store, [id], (newSystems) => {
|
||||
const sys = newSystems[id]
|
||||
if (sys) {
|
||||
setSystem(sys)
|
||||
document.title = `${sys?.name} / Beszel`
|
||||
}
|
||||
})
|
||||
}, [id, systems.length])
|
||||
|
||||
// hide 1m chart time if system agent version is less than 0.13.0
|
||||
useEffect(() => {
|
||||
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
|
||||
$chartTime.set("1h")
|
||||
}
|
||||
}, [system?.info?.v])
|
||||
|
||||
// fetch system details
|
||||
useEffect(() => {
|
||||
// if system.info.m exists, agent is old version without system details
|
||||
if (!system.id || system.info?.m) {
|
||||
return
|
||||
}
|
||||
pb.collection<SystemDetailsRecord>("system_details")
|
||||
.getOne(system.id, {
|
||||
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=60",
|
||||
},
|
||||
})
|
||||
.then(setDetails)
|
||||
}, [system.id])
|
||||
|
||||
// subscribe to realtime metrics if chart time is 1m
|
||||
useEffect(() => {
|
||||
let unsub = () => {}
|
||||
if (!system.id || chartTime !== "1m") {
|
||||
return
|
||||
}
|
||||
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
|
||||
$chartTime.set("1h")
|
||||
return
|
||||
}
|
||||
let isFirst = true
|
||||
pb.realtime
|
||||
.subscribe(
|
||||
`rt_metrics`,
|
||||
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
|
||||
const now = Date.now()
|
||||
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
|
||||
const containerPoint =
|
||||
data.container?.length > 0
|
||||
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
|
||||
: null
|
||||
// on first message, make sure we clear out data from other time periods
|
||||
if (isFirst) {
|
||||
isFirst = false
|
||||
setSystemStats([statsPoint])
|
||||
setContainerData(containerPoint ? [containerPoint] : [])
|
||||
return
|
||||
}
|
||||
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
|
||||
if (containerPoint) {
|
||||
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
|
||||
}
|
||||
},
|
||||
{ query: { system: system.id } }
|
||||
)
|
||||
.then((us) => {
|
||||
unsub = us
|
||||
})
|
||||
return () => {
|
||||
unsub?.()
|
||||
}
|
||||
}, [chartTime, system.id])
|
||||
|
||||
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
|
||||
|
||||
const chartData: ChartData = useMemo(() => {
|
||||
const lastCreated = Math.max(
|
||||
(systemStats.at(-1)?.created as number) ?? 0,
|
||||
(containerData.at(-1)?.created as number) ?? 0
|
||||
)
|
||||
return {
|
||||
systemStats,
|
||||
containerData,
|
||||
chartTime,
|
||||
orientation: direction === "rtl" ? "right" : "left",
|
||||
...getTimeData(chartTime, lastCreated),
|
||||
agentVersion,
|
||||
}
|
||||
}, [systemStats, containerData, direction])
|
||||
|
||||
// Share chart config computation for all container charts
|
||||
const containerChartConfigs = useContainerChartConfigs(containerData)
|
||||
|
||||
// get stats when system "changes." (Not just system to system,
|
||||
// also when new info comes in via systemManager realtime connection, indicating an update)
|
||||
useEffect(() => {
|
||||
if (!system.id || !chartTime || chartTime === "1m") {
|
||||
return
|
||||
}
|
||||
|
||||
const systemId = system.id
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
|
||||
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
|
||||
const requestId = ++statsRequestId.current
|
||||
|
||||
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
|
||||
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
|
||||
|
||||
// Render from cache immediately if available
|
||||
if (cachedSystemStats?.length) {
|
||||
setSystemStats(cachedSystemStats)
|
||||
setContainerData(cachedContainerData || [])
|
||||
setChartLoading(false)
|
||||
|
||||
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
|
||||
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
setChartLoading(true)
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setChartLoading(false)
|
||||
|
||||
// make new system stats
|
||||
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
|
||||
if (systemStats.status === "fulfilled" && systemStats.value.length) {
|
||||
systemData = appendData(systemData, systemStats.value, expectedInterval, 100)
|
||||
cache.set(ss_cache_key, systemData)
|
||||
}
|
||||
setSystemStats(systemData)
|
||||
// make new container stats
|
||||
let containerData = (cache.get(cs_cache_key) || []) as ChartData["containerData"]
|
||||
if (containerStats.status === "fulfilled" && containerStats.value.length) {
|
||||
containerData = appendData(containerData, makeContainerData(containerStats.value), expectedInterval, 100)
|
||||
cache.set(cs_cache_key, containerData)
|
||||
}
|
||||
setContainerData(containerData)
|
||||
})
|
||||
}, [system, chartTime])
|
||||
|
||||
// keyboard navigation between systems
|
||||
// in tabs mode: arrow keys switch tabs, shift+arrow switches systems
|
||||
// in default mode: arrow keys switch systems
|
||||
useEffect(() => {
|
||||
if (!systems.length) {
|
||||
return
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
e.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const isLeft = e.key === "ArrowLeft" || e.key === "h"
|
||||
const isRight = e.key === "ArrowRight" || e.key === "l"
|
||||
if (!isLeft && !isRight) {
|
||||
return
|
||||
}
|
||||
|
||||
// in tabs mode, plain arrows switch tabs, shift+arrows switch systems
|
||||
if (displayMode === "tabs") {
|
||||
if (!e.shiftKey) {
|
||||
// skip if focused in tablist (Radix handles it natively)
|
||||
if (e.target instanceof HTMLElement && e.target.closest('[role="tablist"]')) {
|
||||
return
|
||||
}
|
||||
const tabs = tabsRef.current
|
||||
const currentIdx = tabs.indexOf(activeTab)
|
||||
const nextIdx = isLeft ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length
|
||||
setActiveTab(tabs[nextIdx])
|
||||
return
|
||||
}
|
||||
} else if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentIndex = systems.findIndex((s) => s.id === id)
|
||||
if (currentIndex === -1 || systems.length <= 1) {
|
||||
return
|
||||
}
|
||||
if (isLeft) {
|
||||
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
|
||||
persistChartTime.current = true
|
||||
setActiveTabRaw("core")
|
||||
setMountedTabs(new Set(["core"]))
|
||||
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
|
||||
}
|
||||
if (isRight) {
|
||||
const nextIndex = (currentIndex + 1) % systems.length
|
||||
persistChartTime.current = true
|
||||
setActiveTabRaw("core")
|
||||
setMountedTabs(new Set(["core"]))
|
||||
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
|
||||
}
|
||||
}
|
||||
return listen(document, "keyup", handleKeyUp)
|
||||
}, [id, systems, displayMode, activeTab])
|
||||
|
||||
// derived values
|
||||
const isLongerChart = !["1m", "1h"].includes(chartTime)
|
||||
const showMax = maxValues && isLongerChart
|
||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||
const lastGpus = systemStats.at(-1)?.stats?.g
|
||||
const isPodman = details?.podman ?? system.info?.p ?? false
|
||||
|
||||
let hasGpuData = false
|
||||
let hasGpuEnginesData = false
|
||||
let hasGpuPowerData = false
|
||||
|
||||
if (lastGpus) {
|
||||
hasGpuData = Object.keys(lastGpus).length > 0
|
||||
for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) {
|
||||
const gpus = systemStats[i].stats?.g
|
||||
if (!gpus) continue
|
||||
for (const id in gpus) {
|
||||
if (!hasGpuEnginesData && gpus[id].e !== undefined) {
|
||||
hasGpuEnginesData = true
|
||||
}
|
||||
if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) {
|
||||
hasGpuPowerData = true
|
||||
}
|
||||
if (hasGpuEnginesData && hasGpuPowerData) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
system,
|
||||
systemStats,
|
||||
containerData,
|
||||
chartData,
|
||||
containerChartConfigs,
|
||||
details,
|
||||
grid,
|
||||
setGrid,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
mountedTabs,
|
||||
tabsRef,
|
||||
maxValues,
|
||||
isLongerChart,
|
||||
showMax,
|
||||
dataEmpty,
|
||||
isPodman,
|
||||
lastGpus,
|
||||
hasGpuData,
|
||||
hasGpuEnginesData,
|
||||
hasGpuPowerData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function ({ msg, className }: { msg?: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn(className, "flex flex-col items-center justify-center h-full absolute inset-0")}>
|
||||
{msg ? (
|
||||
<p className={"opacity-60 mb-2 text-center text-sm px-4"}>{msg}</p>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
createStatusPage,
|
||||
updateStatusPage,
|
||||
type StatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
slug: z.string().min(1, "Slug is required").regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens"),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional(),
|
||||
logo: z.string().optional(),
|
||||
favicon: z.string().optional(),
|
||||
theme: z.enum(["light", "dark", "auto"] as const),
|
||||
custom_css: z.string().optional(),
|
||||
public: z.boolean(),
|
||||
show_uptime: z.boolean(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
interface StatusPageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
page?: StatusPage | null
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export function StatusPageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
page,
|
||||
isEdit = false,
|
||||
}: StatusPageDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
title: "",
|
||||
description: "",
|
||||
logo: "",
|
||||
favicon: "",
|
||||
theme: "auto",
|
||||
custom_css: "",
|
||||
public: true,
|
||||
show_uptime: true,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isEdit && page) {
|
||||
form.reset({
|
||||
name: page.name,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
description: page.description || "",
|
||||
logo: page.logo || "",
|
||||
favicon: page.favicon || "",
|
||||
theme: page.theme,
|
||||
custom_css: "",
|
||||
public: page.public,
|
||||
show_uptime: page.show_uptime,
|
||||
})
|
||||
} else if (open && !isEdit) {
|
||||
form.reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
title: "",
|
||||
description: "",
|
||||
logo: "",
|
||||
favicon: "",
|
||||
theme: "auto",
|
||||
custom_css: "",
|
||||
public: true,
|
||||
show_uptime: true,
|
||||
})
|
||||
}
|
||||
}, [open, isEdit, page, form])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createStatusPage,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Status page created successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to create status page",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<FormData> }) =>
|
||||
updateStatusPage(id, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Status page updated successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to update status page",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
if (isEdit && page) {
|
||||
updateMutation.mutate({ id: page.id, data })
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit Status Page" : "Create Status Page"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a public status page to share your service status.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="appearance">Appearance</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Services Status" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-services" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL will be: /status/{field.value}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Service Status Dashboard" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Real-time status of our services"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="auto">Auto (System)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/logo.png" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="favicon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/favicon.ico" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Public Status Page</FormLabel>
|
||||
<FormDescription>
|
||||
Make this status page accessible without authentication
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_uptime"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Show Uptime Percentages</FormLabel>
|
||||
<FormDescription>
|
||||
Display uptime statistics on the status page
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
getStatusPages,
|
||||
deleteStatusPage,
|
||||
getStatusPageUrl,
|
||||
type StatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
import { MoreHorizontal, Plus, ExternalLink, Globe, Lock } from "lucide-react"
|
||||
import { StatusPageDialog } from "./status-page-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
|
||||
export function StatusPagesTable() {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingPage, setEditingPage] = useState<StatusPage | null>(null)
|
||||
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ["status-pages"],
|
||||
queryFn: getStatusPages,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteStatusPage,
|
||||
onSuccess: () => {
|
||||
toast({ title: "Status page deleted successfully" })
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Failed to delete status page",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleEdit = (page: StatusPage) => {
|
||||
setEditingPage(page)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingPage(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this status page?")) {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Status Pages</h2>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Status Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Monitors</TableHead>
|
||||
<TableHead>Visibility</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pages?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No status pages yet. Create one to share your service status publicly.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pages?.map((page) => (
|
||||
<TableRow key={page.id}>
|
||||
<TableCell className="font-medium">{page.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{page.slug}</TableCell>
|
||||
<TableCell>{page.monitor_count}</TableCell>
|
||||
<TableCell>
|
||||
{page.public ? (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
Public
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(page)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{page.public && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={getStatusPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Public Page
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(page.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<StatusPageDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
page={editingPage}
|
||||
isEdit={!!editingPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
||||
import type { SystemdRecord } from "@/types"
|
||||
import { ServiceStatus, ServiceStatusLabels, ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowUpDownIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
MemoryStickIcon,
|
||||
TerminalSquareIcon,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { t } from "@lingui/core/macro"
|
||||
// import { $allSystemsById } from "@/lib/stores"
|
||||
// import { useStore } from "@nanostores/react"
|
||||
|
||||
function getSubStateColor(subState: ServiceSubState) {
|
||||
switch (subState) {
|
||||
case ServiceSubState.Running:
|
||||
return "bg-green-500"
|
||||
case ServiceSubState.Failed:
|
||||
return "bg-red-500"
|
||||
case ServiceSubState.Dead:
|
||||
return "bg-yellow-500"
|
||||
default:
|
||||
return "bg-zinc-500"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const systemdTableCols: ColumnDef<SystemdRecord>[] = [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorFn: (record) => record.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TerminalSquareIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 xl:w-50 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: "system",
|
||||
// accessorFn: (record) => record.system,
|
||||
// sortingFn: (a, b) => {
|
||||
// const allSystems = $allSystemsById.get()
|
||||
// const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
// const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
// return systemNameA.localeCompare(systemNameB)
|
||||
// },
|
||||
// header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
// cell: ({ getValue }) => {
|
||||
// const allSystems = useStore($allSystemsById)
|
||||
// return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: "state",
|
||||
accessorFn: (record) => record.state,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`State`} Icon={ActivityIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const statusValue = getValue() as ServiceStatus
|
||||
const statusLabel = ServiceStatusLabels[statusValue] || "Unknown"
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12">
|
||||
<span className={cn("size-2 me-1.5 rounded-full", getStatusColor(statusValue))} />
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sub",
|
||||
accessorFn: (record) => record.sub,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Sub State`} Icon={ActivityIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const subState = getValue() as ServiceSubState
|
||||
const subStateLabel = ServiceSubStateLabels[subState] || "Unknown"
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12 text-xs capitalize">
|
||||
<span className={cn("size-2 me-1.5 rounded-full", getSubStateColor(subState))} />
|
||||
{subStateLabel}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorFn: (record) => {
|
||||
if (record.sub !== ServiceSubState.Running) {
|
||||
return -1
|
||||
}
|
||||
return record.cpu
|
||||
},
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={`${t`CPU`} (10m)`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
if (val < 0) {
|
||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||
}
|
||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpuPeak",
|
||||
accessorFn: (record) => {
|
||||
if (record.sub !== ServiceSubState.Running) {
|
||||
return -1
|
||||
}
|
||||
return record.cpuPeak ?? 0
|
||||
},
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU Peak`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
if (val < 0) {
|
||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||
}
|
||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
accessorFn: (record) => record.memory,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
if (!val) {
|
||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||
}
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memPeak",
|
||||
accessorFn: (record) => record.memPeak,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory Peak`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
if (!val) {
|
||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||
}
|
||||
const formatted = formatBytes(val, false, undefined, false)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">
|
||||
{hourWithSeconds(new Date(timestamp).toISOString())}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<SystemdRecord>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function getStatusColor(status: ServiceStatus) {
|
||||
switch (status) {
|
||||
case ServiceStatus.Active:
|
||||
return "bg-green-500"
|
||||
case ServiceStatus.Failed:
|
||||
return "bg-red-500"
|
||||
case ServiceStatus.Reloading:
|
||||
case ServiceStatus.Activating:
|
||||
case ServiceStatus.Deactivating:
|
||||
return "bg-yellow-500"
|
||||
default:
|
||||
return "bg-zinc-500"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { pb } from "@/lib/api"
|
||||
import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
|
||||
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
|
||||
import { Separator } from "../ui/separator"
|
||||
|
||||
export default function SystemdTable({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<SystemdRecord[]>([])
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-sd-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
// clear old data when systemId changes
|
||||
useEffect(() => {
|
||||
return setData([])
|
||||
}, [systemId])
|
||||
|
||||
useEffect(() => {
|
||||
const lastUpdated = data[0]?.updated ?? 0
|
||||
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<SystemdRecord>("systemd_services")
|
||||
.getList(0, 2000, {
|
||||
fields: "name,state,sub,cpu,cpuPeak,memory,memPeak,updated",
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then(
|
||||
({ items }) =>
|
||||
items.length &&
|
||||
setData((curItems) => {
|
||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||
const systemdNames = new Set()
|
||||
const newItems: SystemdRecord[] = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
systemdNames.add(item.name)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems) {
|
||||
if (!systemdNames.has(item.name) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull system containers after every system update
|
||||
if (!systemId) {
|
||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// exclude initial load of systems
|
||||
if (Date.now() - loadTime > 500) {
|
||||
fetchData(systemId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// if systemId, fetch containers after the system is updated
|
||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
// don't fetch data if the last update is less than 9.5 minutes
|
||||
if (lastUpdated > Date.now() - 9.5 * 60 * 1000) {
|
||||
return
|
||||
}
|
||||
fetchData(systemId)
|
||||
})
|
||||
}, [systemId])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
// columns: systemdTableCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||
columns: systemdTableCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const service = row.original
|
||||
const systemName = $allSystemsById.get()[service.system]?.name ?? ""
|
||||
const name = service.name ?? ""
|
||||
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
||||
const subState = service.sub ?? ""
|
||||
const searchString = `${systemName} ${name} ${statusLabel} ${subState}`.toLowerCase()
|
||||
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const statusTotals = useMemo(() => {
|
||||
const totals = [0, 0, 0, 0, 0, 0]
|
||||
for (const service of data) {
|
||||
totals[service.state]++
|
||||
}
|
||||
return totals
|
||||
}, [data])
|
||||
|
||||
if (!data.length && !globalFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>Systemd Services</Trans>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||
<Trans>Total: {data.length}</Trans>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||
<Trans>Updated every 10 minutes.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllSystemdTable table={table} rows={rows} colLength={visibleColumns.length} systemId={systemId} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllSystemdTable = memo(function AllSystemdTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
systemId,
|
||||
}: {
|
||||
table: TableType<SystemdRecord>
|
||||
rows: Row<SystemdRecord>[]
|
||||
colLength: number
|
||||
systemId?: string
|
||||
}) {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeService = useRef<SystemdRecord | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const openSheet = (service: SystemdRecord) => {
|
||||
activeService.current = service
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<SystemdTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <SystemdTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No results.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<SystemdSheet
|
||||
sheetOpen={sheetOpen}
|
||||
setSheetOpen={setSheetOpen}
|
||||
activeService={activeService}
|
||||
systemId={systemId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function SystemdSheet({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
activeService,
|
||||
systemId,
|
||||
}: {
|
||||
sheetOpen: boolean
|
||||
setSheetOpen: (open: boolean) => void
|
||||
activeService: React.RefObject<SystemdRecord | null>
|
||||
systemId?: string
|
||||
}) {
|
||||
const service = activeService.current
|
||||
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetOpen || !service) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
let cancelled = false
|
||||
setDetails(null)
|
||||
setIsLoading(true)
|
||||
|
||||
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
|
||||
query: {
|
||||
system: systemId,
|
||||
service: service.name,
|
||||
},
|
||||
})
|
||||
.then(({ details }) => {
|
||||
if (cancelled) return
|
||||
if (details) {
|
||||
setDetails(details)
|
||||
} else {
|
||||
setDetails(null)
|
||||
setError(t`No results found.`)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setError(err?.message ?? "Failed to load service details")
|
||||
setDetails(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sheetOpen, service, systemId])
|
||||
|
||||
if (!service) return null
|
||||
|
||||
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
||||
const subStateLabel = ServiceSubStateLabels[service.sub as ServiceSubState] ?? ""
|
||||
|
||||
const notAvailable = <span className="text-muted-foreground">N/A</span>
|
||||
|
||||
const formatMemory = (value?: number | null) => {
|
||||
if (value === undefined || value === null) {
|
||||
return value === null ? t`Unlimited` : undefined
|
||||
}
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, undefined, false)
|
||||
const digits = convertedValue >= 10 ? 1 : 2
|
||||
return `${decimalString(convertedValue, digits)} ${unit}`
|
||||
}
|
||||
|
||||
const formatCpuTime = (ns?: number) => {
|
||||
if (!ns) return undefined
|
||||
const seconds = ns / 1_000_000_000
|
||||
if (seconds >= 3600) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${minutes}m ${secs}s`
|
||||
}
|
||||
if (seconds >= 1) {
|
||||
return `${decimalString(seconds, 2)}s`
|
||||
}
|
||||
return `${decimalString(seconds * 1000, 2)}ms`
|
||||
}
|
||||
|
||||
const formatTasks = (current?: number, max?: number) => {
|
||||
const hasCurrent = typeof current === "number" && current >= 0
|
||||
const hasMax = typeof max === "number" && max > 0 && max !== null
|
||||
if (!hasCurrent && !hasMax) {
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{hasCurrent ? current : notAvailable}
|
||||
{hasMax && <span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${max})`}</span>}
|
||||
{max === null && (
|
||||
<span className="text-muted-foreground ms-1.5">{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp?: number) => {
|
||||
if (!timestamp) return undefined
|
||||
// systemd timestamps are in microseconds, convert to milliseconds for JavaScript Date
|
||||
const date = new Date(timestamp / 1000)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const activeStateValue = (() => {
|
||||
const stateText = details?.ActiveState
|
||||
? details.SubState
|
||||
? `${details.ActiveState} (${details.SubState})`
|
||||
: details.ActiveState
|
||||
: subStateLabel
|
||||
? `${statusLabel} (${subStateLabel})`
|
||||
: statusLabel
|
||||
|
||||
for (const [index, status] of ServiceStatusLabels.entries()) {
|
||||
if (details?.ActiveState?.toLowerCase() === status.toLowerCase()) {
|
||||
service.state = index as ServiceStatus
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor(service.state))} />
|
||||
{stateText}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
|
||||
const statusTextValue = details?.Result
|
||||
|
||||
const cpuTime = formatCpuTime(details?.CPUUsageNSec)
|
||||
const tasks = formatTasks(details?.TasksCurrent, details?.TasksMax)
|
||||
const memoryCurrent = formatMemory(details?.MemoryCurrent)
|
||||
const memoryPeak = formatMemory(details?.MemoryPeak)
|
||||
const memoryLimit = formatMemory(details?.MemoryLimit)
|
||||
const restartsValue = typeof details?.NRestarts === "number" ? details.NRestarts : undefined
|
||||
const mainPidValue = typeof details?.MainPID === "number" && details.MainPID > 0 ? details.MainPID : undefined
|
||||
const execMainPidValue =
|
||||
typeof details?.ExecMainPID === "number" && details.ExecMainPID > 0 && details.ExecMainPID !== details?.MainPID
|
||||
? details.ExecMainPID
|
||||
: undefined
|
||||
const activeEnterTimestamp = formatTimestamp(details?.ActiveEnterTimestamp)
|
||||
const activeExitTimestamp = formatTimestamp(details?.ActiveExitTimestamp)
|
||||
const inactiveEnterTimestamp = formatTimestamp(details?.InactiveEnterTimestamp)
|
||||
const execMainStartTimestamp = undefined // Property not available in current systemd interface
|
||||
|
||||
const renderRow = (key: string, label: ReactNode, value?: ReactNode, alwaysShow = false) => {
|
||||
if (!alwaysShow && (value === undefined || value === null || value === "")) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<tr key={key} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2 font-medium bg-muted dark:bg-muted/40 align-top w-35">{label}</td>
|
||||
<td className="px-3 py-2">{value ?? notAvailable}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
const capitalize = (str: string) => `${str.charAt(0).toUpperCase()}${str.slice(1).toLowerCase()}`
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle>
|
||||
<Trans>Service Details</Trans>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="grid gap-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
<Trans>Loading...</Trans>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Error</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{renderRow("name", t`Name`, service.name, true)}
|
||||
{renderRow("description", t`Description`, details?.Description, true)}
|
||||
{renderRow("loadState", t`Load state`, details?.LoadState, true)}
|
||||
{renderRow(
|
||||
"bootState",
|
||||
t`Boot state`,
|
||||
<div className="flex items-center">
|
||||
{details?.UnitFileState}
|
||||
{details?.UnitFilePreset && (
|
||||
<span className="text-muted-foreground ms-1.5">(preset: {details?.UnitFilePreset})</span>
|
||||
)}
|
||||
</div>,
|
||||
true
|
||||
)}
|
||||
{renderRow("unitFile", t`Unit file`, details?.FragmentPath, true)}
|
||||
{renderRow("active", t`Active state`, activeStateValue, true)}
|
||||
{renderRow("status", t`Status`, statusTextValue, true)}
|
||||
{renderRow(
|
||||
"documentation",
|
||||
t`Documentation`,
|
||||
Array.isArray(details?.Documentation) && details.Documentation.length > 0
|
||||
? details.Documentation.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">
|
||||
<Trans>Runtime Metrics</Trans>
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
|
||||
{renderRow("execMainPid", t`Exec main PID`, execMainPidValue)}
|
||||
{renderRow("tasks", t`Tasks`, tasks, true)}
|
||||
{renderRow("cpuTime", t`CPU time`, cpuTime)}
|
||||
{renderRow("memory", t`Memory`, memoryCurrent, true)}
|
||||
{renderRow("memoryPeak", capitalize(t`Memory Peak`), memoryPeak)}
|
||||
{renderRow("memoryLimit", t`Memory limit`, memoryLimit)}
|
||||
{renderRow("restarts", t`Restarts`, restartsValue, true)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden has-[tr]:block">
|
||||
<h3 className="text-sm font-medium mb-3">
|
||||
<Trans>Relationships</Trans>
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{renderRow(
|
||||
"wants",
|
||||
t`Wants`,
|
||||
Array.isArray(details?.Wants) && details.Wants.length > 0 ? details.Wants.join(", ") : undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"requires",
|
||||
t`Requires`,
|
||||
Array.isArray(details?.Requires) && details.Requires.length > 0
|
||||
? details.Requires.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"requiredBy",
|
||||
t`Required by`,
|
||||
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
|
||||
? details.RequiredBy.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"conflicts",
|
||||
t`Conflicts`,
|
||||
Array.isArray(details?.Conflicts) && details.Conflicts.length > 0
|
||||
? details.Conflicts.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"before",
|
||||
t`Before`,
|
||||
Array.isArray(details?.Before) && details.Before.length > 0 ? details.Before.join(", ") : undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"after",
|
||||
t`After`,
|
||||
Array.isArray(details?.After) && details.After.length > 0 ? details.After.join(", ") : undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"triggers",
|
||||
t`Triggers`,
|
||||
Array.isArray(details?.Triggers) && details.Triggers.length > 0
|
||||
? details.Triggers.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
{renderRow(
|
||||
"triggeredBy",
|
||||
t`Triggered by`,
|
||||
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
|
||||
? details.TriggeredBy.join(", ")
|
||||
: undefined
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden has-[tr]:block">
|
||||
<h3 className="text-sm font-medium mb-3">
|
||||
<Trans>Lifecycle</Trans>
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{renderRow("activeSince", t`Became active`, activeEnterTimestamp)}
|
||||
{service.state !== ServiceStatus.Active &&
|
||||
renderRow("lastActive", t`Exited active`, activeExitTimestamp)}
|
||||
{renderRow("inactiveSince", t`Became inactive`, inactiveEnterTimestamp)}
|
||||
{renderRow("execMainStart", t`Process started`, execMainStartTimestamp)}
|
||||
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
|
||||
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden has-[tr]:block">
|
||||
<h3 className="text-sm font-medium mb-3">
|
||||
<Trans>Capabilities</Trans>
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{renderRow("canStart", t`Can start`, details?.CanStart ? t`Yes` : t`No`)}
|
||||
{renderRow("canStop", t`Can stop`, details?.CanStop ? t`Yes` : t`No`)}
|
||||
{renderRow("canReload", t`Can reload`, details?.CanReload ? t`Yes` : t`No`)}
|
||||
{/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? t`Yes` : t`No`)}
|
||||
{renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr 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>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const SystemdTableRow = memo(function SystemdTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<SystemdRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (service: SystemdRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,685 @@
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||
import type { ClassValue } from "clsx"
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
ChevronRightSquareIcon,
|
||||
ClockArrowUp,
|
||||
CopyIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
MoreHorizontalIcon,
|
||||
PauseCircleIcon,
|
||||
PenBoxIcon,
|
||||
PlayCircleIcon,
|
||||
ServerIcon,
|
||||
TerminalSquareIcon,
|
||||
Trash2Icon,
|
||||
WifiIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useMemo, useRef, useState } from "react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||
import {
|
||||
cn,
|
||||
copyToClipboard,
|
||||
decimalString,
|
||||
formatBytes,
|
||||
formatTemperature,
|
||||
parseSemVer,
|
||||
secondsToUptimeString,
|
||||
} from "@/lib/utils"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import { SystemDialog } from "../add-system"
|
||||
import AlertButton from "../alerts/alert-button"
|
||||
import { $router, Link } from "../router"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "../ui/button"
|
||||
import { Dialog } from "../ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu"
|
||||
import {
|
||||
BatteryMediumIcon,
|
||||
EthernetIcon,
|
||||
GpuIcon,
|
||||
HourglassIcon,
|
||||
ThermometerIcon,
|
||||
WebSocketIcon,
|
||||
BatteryHighIcon,
|
||||
BatteryLowIcon,
|
||||
PlugChargingIcon,
|
||||
BatteryFullIcon,
|
||||
} from "../ui/icons"
|
||||
|
||||
const STATUS_COLORS = {
|
||||
[SystemStatus.Up]: "bg-green-500",
|
||||
[SystemStatus.Down]: "bg-red-500",
|
||||
[SystemStatus.Paused]: "bg-primary/40",
|
||||
[SystemStatus.Pending]: "bg-yellow-500",
|
||||
} as const
|
||||
|
||||
function getMeterStateByThresholds(value: number, warn = 65, crit = 90): MeterState {
|
||||
return value >= crit ? MeterState.Crit : value >= warn ? MeterState.Warn : MeterState.Good
|
||||
}
|
||||
|
||||
/**
|
||||
* @param viewMode - "table" or "grid"
|
||||
* @returns - Column definitions for the systems table
|
||||
*/
|
||||
export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||
return [
|
||||
{
|
||||
// size: 200,
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
accessorKey: "name",
|
||||
id: "system",
|
||||
name: () => t`System`,
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
filterFn: (() => {
|
||||
let filterInput = ""
|
||||
let filterInputLower = ""
|
||||
const nameCache = new Map<string, string>()
|
||||
const statusTranslations = {
|
||||
[SystemStatus.Up]: t`Up`.toLowerCase(),
|
||||
[SystemStatus.Down]: t`Down`.toLowerCase(),
|
||||
[SystemStatus.Paused]: t`Paused`.toLowerCase(),
|
||||
} as const
|
||||
|
||||
// match filter value against name or translated status
|
||||
return (row, _, newFilterInput) => {
|
||||
const sys = row.original
|
||||
if (sys.host.includes(newFilterInput) || sys.info.v?.includes(newFilterInput)) {
|
||||
return true
|
||||
}
|
||||
if (newFilterInput !== filterInput) {
|
||||
filterInput = newFilterInput
|
||||
filterInputLower = newFilterInput.toLowerCase()
|
||||
}
|
||||
let nameLower = nameCache.get(sys.name)
|
||||
if (nameLower === undefined) {
|
||||
nameLower = sys.name.toLowerCase()
|
||||
nameCache.set(sys.name, nameLower)
|
||||
}
|
||||
if (nameLower.includes(filterInputLower)) {
|
||||
return true
|
||||
}
|
||||
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
|
||||
return statusLower?.includes(filterInputLower) || false
|
||||
}
|
||||
})(),
|
||||
enableHiding: false,
|
||||
invertSorting: false,
|
||||
Icon: ServerIcon,
|
||||
cell: (info) => {
|
||||
const { name, id } = info.row.original
|
||||
const longestName = useStore($longestSystemNameLen)
|
||||
const linkUrl = getPagePath($router, "system", { id })
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1">
|
||||
<IndicatorDot system={info.row.original} />
|
||||
<Link
|
||||
href={linkUrl}
|
||||
tabIndex={-1}
|
||||
className="truncate z-10 relative"
|
||||
style={{ width: `${longestName / 1.05}ch` }}
|
||||
onMouseEnter={(e) => {
|
||||
// set title on hover if text is truncated to show full name
|
||||
const a = e.currentTarget
|
||||
if (a.scrollWidth > a.clientWidth) {
|
||||
a.title = name
|
||||
} else {
|
||||
a.removeAttribute("title")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</span>
|
||||
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>
|
||||
</>
|
||||
)
|
||||
},
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.cpu || undefined,
|
||||
id: "cpu",
|
||||
name: () => t`CPU`,
|
||||
cell: TableCellWithMeter,
|
||||
Icon: CpuIcon,
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
// accessorKey: "info.mp",
|
||||
accessorFn: ({ info }) => info.mp || undefined,
|
||||
id: "memory",
|
||||
name: () => t`Memory`,
|
||||
cell: TableCellWithMeter,
|
||||
Icon: MemoryStickIcon,
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.dp || undefined,
|
||||
id: "disk",
|
||||
name: () => t`Disk`,
|
||||
cell: (info: CellContext<SystemRecord, unknown>) =>
|
||||
info.row.original.info.efs ? DiskCellWithMultiple(info) : TableCellWithMeter(info),
|
||||
Icon: HardDriveIcon,
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.g || undefined,
|
||||
id: "gpu",
|
||||
name: () => "GPU",
|
||||
cell: TableCellWithMeter,
|
||||
Icon: GpuIcon,
|
||||
header: sortableHeader,
|
||||
},
|
||||
{
|
||||
id: "loadAverage",
|
||||
accessorFn: ({ info }) => info.la?.reduce((acc, curr) => acc + curr, 0),
|
||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||
size: 0,
|
||||
Icon: HourglassIcon,
|
||||
header: sortableHeader,
|
||||
cell(info: CellContext<SystemRecord, unknown>) {
|
||||
const { info: sysInfo, status } = info.row.original
|
||||
const { major, minor } = parseSemVer(sysInfo.v)
|
||||
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
|
||||
const loadAverages = sysInfo.la || []
|
||||
|
||||
const max = Math.max(...loadAverages)
|
||||
if (max === 0 && (status === SystemStatus.Paused || (major < 1 && minor < 13))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedLoad = max / (sysInfo.t ?? 1)
|
||||
const threshold = getMeterStateByThresholds(normalizedLoad * 100, colorWarn, colorCrit)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||
<span
|
||||
className={cn("inline-block size-2 rounded-full me-0.5", {
|
||||
[STATUS_COLORS[SystemStatus.Up]]: threshold === MeterState.Good,
|
||||
[STATUS_COLORS[SystemStatus.Pending]]: threshold === MeterState.Warn,
|
||||
[STATUS_COLORS[SystemStatus.Down]]: threshold === MeterState.Crit,
|
||||
[STATUS_COLORS[SystemStatus.Paused]]: status !== SystemStatus.Up,
|
||||
})}
|
||||
/>
|
||||
{loadAverages?.map((la, i) => (
|
||||
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info, status }) => (status !== SystemStatus.Up ? undefined : info.bb),
|
||||
id: "net",
|
||||
name: () => t`Net`,
|
||||
size: 0,
|
||||
Icon: EthernetIcon,
|
||||
header: sortableHeader,
|
||||
sortUndefined: "last",
|
||||
cell(info) {
|
||||
const val = info.getValue() as number | undefined
|
||||
if (val === undefined) {
|
||||
return null
|
||||
}
|
||||
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.dt,
|
||||
id: "temp",
|
||||
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
||||
size: 50,
|
||||
hideSort: true,
|
||||
Icon: ThermometerIcon,
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const val = info.getValue() as number
|
||||
const userSettings = useStore($userSettings, { keys: ["unitTemp"] })
|
||||
if (!val) {
|
||||
return null
|
||||
}
|
||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||
return (
|
||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.bat?.[0],
|
||||
id: "battery",
|
||||
name: () => t({ message: "Bat", comment: "Battery label in systems table header" }),
|
||||
size: 70,
|
||||
Icon: BatteryMediumIcon,
|
||||
header: sortableHeader,
|
||||
hideSort: true,
|
||||
cell(info) {
|
||||
const [pct, state] = info.row.original.info.bat ?? []
|
||||
if (pct === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
let Icon = PlugChargingIcon
|
||||
let iconColor = "text-muted-foreground"
|
||||
|
||||
if (state !== BatteryState.Charging) {
|
||||
if (pct < 25) {
|
||||
iconColor = pct < 11 ? "text-red-500" : "text-yellow-500"
|
||||
Icon = BatteryLowIcon
|
||||
} else if (pct < 75) {
|
||||
Icon = BatteryMediumIcon
|
||||
} else if (pct < 95) {
|
||||
Icon = BatteryHighIcon
|
||||
} else {
|
||||
Icon = BatteryFullIcon
|
||||
}
|
||||
}
|
||||
|
||||
const stateLabel =
|
||||
state !== undefined ? (batteryStateTranslations[state as BatteryState]?.() ?? undefined) : undefined
|
||||
|
||||
return (
|
||||
<Link
|
||||
tabIndex={-1}
|
||||
href={getPagePath($router, "system", { id: info.row.original.id })}
|
||||
className="flex items-center gap-1 tabular-nums tracking-tight relative z-10"
|
||||
title={stateLabel}
|
||||
>
|
||||
<Icon className={cn("size-3.5", iconColor)} />
|
||||
<span className="min-w-10">{pct}%</span>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.sv?.[0],
|
||||
id: "services",
|
||||
name: () => t`Services`,
|
||||
size: 50,
|
||||
Icon: TerminalSquareIcon,
|
||||
header: sortableHeader,
|
||||
hideSort: true,
|
||||
sortingFn: (a, b) => {
|
||||
// sort priorities: 1) failed services, 2) total services
|
||||
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
|
||||
const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0]
|
||||
if (numFailedA !== numFailedB) {
|
||||
return numFailedA - numFailedB
|
||||
}
|
||||
return totalCountA - totalCountB
|
||||
},
|
||||
cell(info) {
|
||||
const sys = info.row.original
|
||||
const [totalCount, numFailed] = sys.info.sv ?? [0, 0]
|
||||
if (sys.status !== SystemStatus.Up || totalCount === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
|
||||
<span
|
||||
className={cn("block size-2 rounded-full", {
|
||||
[STATUS_COLORS[SystemStatus.Down]]: numFailed > 0,
|
||||
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
||||
})}
|
||||
/>
|
||||
{totalCount}{" "}
|
||||
<span className="text-muted-foreground text-sm -ms-0.5">
|
||||
({t`Failed`.toLowerCase()}: {numFailed})
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.u || undefined,
|
||||
id: "uptime",
|
||||
name: () => t`Uptime`,
|
||||
size: 50,
|
||||
Icon: ClockArrowUp,
|
||||
header: sortableHeader,
|
||||
hideSort: true,
|
||||
cell(info) {
|
||||
const uptime = info.getValue() as number
|
||||
if (!uptime) {
|
||||
return null
|
||||
}
|
||||
return <span className="tabular-nums whitespace-nowrap">{secondsToUptimeString(uptime)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.v,
|
||||
id: "agent",
|
||||
name: () => t`Agent`,
|
||||
size: 50,
|
||||
Icon: WifiIcon,
|
||||
hideSort: true,
|
||||
header: sortableHeader,
|
||||
cell(info) {
|
||||
const version = info.getValue() as string
|
||||
if (!version) {
|
||||
return null
|
||||
}
|
||||
const system = info.row.original
|
||||
const color = {
|
||||
"text-green-500": version === globalThis.BESZEL.HUB_VERSION,
|
||||
"text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION,
|
||||
"text-red-500": system.status !== SystemStatus.Up,
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id: system.id })}
|
||||
className={cn(
|
||||
"flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10",
|
||||
viewMode === "table" && "ps-0.5"
|
||||
)}
|
||||
tabIndex={-1}
|
||||
title={connectionTypeLabels[system.info.ct as ConnectionType]}
|
||||
role="none"
|
||||
>
|
||||
{system.info.ct === ConnectionType.WebSocket && (
|
||||
<WebSocketIcon className={cn("size-3 pointer-events-none", color)} />
|
||||
)}
|
||||
{system.info.ct === ConnectionType.SSH && (
|
||||
<ChevronRightSquareIcon className={cn("size-3 pointer-events-none", color)} />
|
||||
)}
|
||||
{!system.info.ct && <IndicatorDot system={system} className={cn(color, "bg-current mx-0.5")} />}
|
||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
// @ts-expect-error
|
||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||
size: 50,
|
||||
cell: ({ row }) => (
|
||||
<div className="relative z-10 flex justify-end items-center gap-1 -ms-3">
|
||||
<AlertButton system={row.original} />
|
||||
<ActionsButton system={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<SystemRecord>[]
|
||||
}
|
||||
|
||||
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||
const { column } = context
|
||||
// @ts-expect-error
|
||||
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("h-9 px-3 flex duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="me-2 size-4" />}
|
||||
{name()}
|
||||
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
||||
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
|
||||
const val = Number(info.getValue()) || 0
|
||||
const threshold = getMeterStateByThresholds(val, colorWarn, colorCrit)
|
||||
const meterClass = cn(
|
||||
"h-full",
|
||||
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
||||
<span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
||||
<span className={meterClass} style={{ width: `${val}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||
const { colorWarn = 65, colorCrit = 90 } = useStore($userSettings, { keys: ["colorWarn", "colorCrit"] })
|
||||
const { info: sysInfo, status, id } = info.row.original
|
||||
const extraFs = Object.entries(sysInfo.efs ?? {})
|
||||
const rootDiskPct = sysInfo.dp
|
||||
|
||||
// sort extra disks by percentage descending
|
||||
extraFs.sort((a, b) => b[1] - a[1])
|
||||
|
||||
function getIndicatorColor(pct: number) {
|
||||
const threshold = getMeterStateByThresholds(pct, colorWarn, colorCrit)
|
||||
return (
|
||||
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||
STATUS_COLORS.down
|
||||
)
|
||||
}
|
||||
|
||||
function getMeterClass(pct: number) {
|
||||
return cn("h-full", getIndicatorColor(pct))
|
||||
}
|
||||
|
||||
// Extra disk indicators (max 3 dots - one per state if any disk exists in range)
|
||||
const stateColors = [STATUS_COLORS.up, STATUS_COLORS.pending, STATUS_COLORS.down]
|
||||
const extraDiskIndicators =
|
||||
status !== SystemStatus.Up
|
||||
? []
|
||||
: [...new Set(extraFs.map(([, pct]) => getMeterStateByThresholds(pct, colorWarn, colorCrit)))]
|
||||
.sort()
|
||||
.map((state) => stateColors[state])
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id })}
|
||||
tabIndex={-1}
|
||||
className="flex flex-col gap-0.5 w-full relative z-10"
|
||||
>
|
||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-8 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative">
|
||||
{/* Root disk */}
|
||||
<span
|
||||
className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
|
||||
style={{ width: `${rootDiskPct}%` }}
|
||||
></span>
|
||||
{/* Extra disk indicators */}
|
||||
{extraDiskIndicators.map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
className={cn("size-1.5 rounded-full shrink-0 outline-[0.5px] outline-muted", color)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
||||
<div className="grid gap-1">
|
||||
<div className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums">
|
||||
<Trans context="Root disk label">Root</Trans>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{extraFs.map(([name, pct]) => {
|
||||
return (
|
||||
<div key={name} className="grid gap-0.5">
|
||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">
|
||||
{name}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 size-2 rounded-full", className)}
|
||||
// style={{ marginBottom: "-1px" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const editOpened = useRef(false)
|
||||
const { t } = useLingui()
|
||||
const { id, status, host, name } = system
|
||||
|
||||
return useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={"icon"}>
|
||||
<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 === SystemStatus.Paused ? SystemStatus.Pending : SystemStatus.Paused,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{status === SystemStatus.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(name)}>
|
||||
<CopyIcon className="me-2.5 size-4" />
|
||||
<Trans>Copy name</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>
|
||||
</>
|
||||
)
|
||||
}, [id, status, host, name, system, t, deleteOpen, editOpen])
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
Settings2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores"
|
||||
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import AlertButton from "../alerts/alert-button"
|
||||
import { $router, Link } from "../router"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | SystemRecord["status"]
|
||||
|
||||
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
|
||||
|
||||
export default function SystemsTable() {
|
||||
const data = useStore($systems)
|
||||
const downSystems = $downSystems.get()
|
||||
const upSystems = $upSystems.get()
|
||||
const pausedSystems = $pausedSystems.get()
|
||||
const { i18n, t } = useLingui()
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
"sortMode",
|
||||
[{ id: "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
|
||||
|
||||
const locale = i18n.locale
|
||||
|
||||
// Filter data based on status filter
|
||||
const filteredData = useMemo(() => {
|
||||
if (statusFilter === "all") {
|
||||
return data
|
||||
}
|
||||
if (statusFilter === SystemStatus.Up) {
|
||||
return Object.values(upSystems) ?? []
|
||||
}
|
||||
if (statusFilter === SystemStatus.Down) {
|
||||
return Object.values(downSystems) ?? []
|
||||
}
|
||||
return Object.values(pausedSystems) ?? []
|
||||
}, [data, statusFilter])
|
||||
|
||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||
"viewMode",
|
||||
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== undefined) {
|
||||
table.getColumn("system")?.setFilterValue(filter)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns: columnDefs,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
defaultColumn: {
|
||||
invertSorting: true,
|
||||
sortUndefined: "last",
|
||||
minSize: 0,
|
||||
size: 900,
|
||||
maxSize: 900,
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const columns = table.getAllColumns()
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const [upSystemsLength, downSystemsLength, pausedSystemsLength] = useMemo(() => {
|
||||
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
|
||||
}, [upSystems, downSystems, pausedSystems])
|
||||
|
||||
const CardHead = useMemo(() => {
|
||||
return (
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Systems</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a system to view more information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
value={filter}
|
||||
className="ps-4 pe-10 w-full"
|
||||
/>
|
||||
{filter && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Settings2Icon className="me-1.5 size-4 opacity-80" />
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-s md:divide-y-0">
|
||||
<div className="border-r">
|
||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Layout</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={viewMode}
|
||||
onValueChange={(view) => setViewMode(view as ViewMode)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||
<LayoutListIcon className="size-4" />
|
||||
<Trans>Table</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Grid</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="border-r">
|
||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||
<FilterIcon className="size-4" />
|
||||
<Trans>Status</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>All Systems</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Up ({upSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Down ({downSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>Paused ({pausedSystemsLength})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="border-r">
|
||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
<Trans>Sort By</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1 pb-1">
|
||||
{columns.map((column) => {
|
||||
if (!column.getCanSort()) return null
|
||||
let Icon = <span className="w-6"></span>
|
||||
// if current sort column, show sort direction
|
||||
if (sorting[0]?.id === column.id) {
|
||||
if (sorting[0]?.desc) {
|
||||
Icon = <ArrowUpIcon className="me-2 size-4" />
|
||||
} else {
|
||||
Icon = <ArrowDownIcon className="me-2 size-4" />
|
||||
}
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
{Icon}
|
||||
{/* @ts-ignore */}
|
||||
{column.columnDef.name()}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||
<EyeIcon className="size-4" />
|
||||
<Trans>Visible Fields</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1.5 pb-1">
|
||||
{columns
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{column.columnDef.name()}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)
|
||||
}, [
|
||||
visibleColumns.length,
|
||||
sorting,
|
||||
viewMode,
|
||||
locale,
|
||||
statusFilter,
|
||||
upSystemsLength,
|
||||
downSystemsLength,
|
||||
pausedSystemsLength,
|
||||
filter,
|
||||
])
|
||||
|
||||
return (
|
||||
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
{CardHead}
|
||||
{viewMode === "table" ? (
|
||||
// table layout
|
||||
<div className="rounded-md">
|
||||
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||
</div>
|
||||
) : (
|
||||
// grid layout
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rows?.length ? (
|
||||
rows.map((row) => {
|
||||
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-full text-center py-8">
|
||||
<Trans>No systems found.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllSystemsTable = memo(
|
||||
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => (rows.length > 10 ? 56 : 60),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full">
|
||||
<SystemsTableHead table={table} />
|
||||
<TableBody onMouseEnter={preloadSystemDetail}>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<SystemRecord>
|
||||
return (
|
||||
<SystemTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
virtualRow={virtualRow}
|
||||
length={rows.length}
|
||||
colLength={colLength}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No systems found.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
|
||||
const { t } = useLingui()
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-1.5" key={header.id}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const SystemTableRow = memo(
|
||||
({
|
||||
row,
|
||||
virtualRow,
|
||||
colLength,
|
||||
}: {
|
||||
row: Row<SystemRecord>
|
||||
virtualRow: VirtualItem
|
||||
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 relative safari:transform-3d", {
|
||||
"opacity-50": system.status === SystemStatus.Paused,
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
className="py-0 ps-4.5"
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}, [system, system.status, 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
|
||||
onMouseEnter={preloadSystemDetail}
|
||||
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 === SystemStatus.Paused,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
|
||||
<div className="flex items-center gap-1 w-full overflow-hidden">
|
||||
<h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<IndicatorDot system={system} />
|
||||
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||
</div>
|
||||
</h3>
|
||||
{table.getColumn("actions")?.getIsVisible() && (
|
||||
<div className="flex gap-1 shrink-0 relative z-10">
|
||||
<AlertButton system={system} />
|
||||
<ActionsButton system={system} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm px-5 pt-3.5 pb-4">
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}>
|
||||
{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-expect-error
|
||||
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
|
||||
return (
|
||||
<>
|
||||
<div key={`${column.id}-icon`} className="flex items-center">
|
||||
{column.id === "lastSeen" ? (
|
||||
<EyeIcon className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
Icon && <Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3">
|
||||
{name()}:
|
||||
</div>
|
||||
<div key={`${column.id}-value`} className="flex items-center">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id: row.original.id })}
|
||||
className="inset-0 absolute w-full h-full"
|
||||
>
|
||||
<span className="sr-only">{row.original.name}</span>
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
}, [system, colLength, t])
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeProviderContext)
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import * as React from "react"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("grid gap-2 text-start", className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
// import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// const alertVariants = cva(
|
||||
// "relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
// {
|
||||
// variants: {
|
||||
// variant: {
|
||||
// default: "bg-background text-foreground",
|
||||
// destructive:
|
||||
// "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
// },
|
||||
// },
|
||||
// defaultVariants: {
|
||||
// variant: "default",
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
// >(({ className, variant, ...props }, ref) => (
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 -mt-0.5 font-medium leading-tight tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-200 text-green-800",
|
||||
danger: "border-transparent bg-red-200 text-red-800",
|
||||
warning: "border-transparent bg-yellow-200 text-yellow-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent/70 hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("grid gap-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { JSX } from "react"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import { chartTimeData, cn } from "@/lib/utils"
|
||||
import type { ChartData } from "@/types"
|
||||
import { Separator } from "./separator"
|
||||
import { AxisDomain } from "recharts/types/util/types"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
||||
}
|
||||
|
||||
// type ChartContextProps = {
|
||||
// config: ChartConfig
|
||||
// }
|
||||
|
||||
// const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
// function useChart() {
|
||||
// const context = React.useContext(ChartContext)
|
||||
|
||||
// if (!context) {
|
||||
// throw new Error('useChart must be used within a <ChartContainer />')
|
||||
// }
|
||||
|
||||
// return context
|
||||
// }
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
// config: ChartConfig
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
|
||||
}
|
||||
>(({ id, className, children, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
//<ChartContext.Provider value={{ config }}>
|
||||
//</ChartContext.Provider>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* <ChartStyle id={chartId} config={config} /> */}
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
// const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
// const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
|
||||
|
||||
// if (!colorConfig.length) {
|
||||
// return null
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <style
|
||||
// dangerouslySetInnerHTML={{
|
||||
// __html: Object.entries(THEMES).map(
|
||||
// ([theme, prefix]) => `
|
||||
// ${prefix} [data-chart=${id}] {
|
||||
// ${colorConfig
|
||||
// .map(([key, itemConfig]) => {
|
||||
// const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||
// return color ? ` --color-${key}: ${color};` : null
|
||||
// })
|
||||
// .join('\n')}
|
||||
// }
|
||||
// `
|
||||
// ),
|
||||
// }}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
unit?: string
|
||||
filter?: string
|
||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||
truncate?: boolean
|
||||
showTotal?: boolean
|
||||
totalLabel?: React.ReactNode
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "line",
|
||||
hideLabel = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
unit,
|
||||
filter,
|
||||
itemSorter,
|
||||
contentFormatter: content = undefined,
|
||||
truncate = false,
|
||||
showTotal = false,
|
||||
totalLabel,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// const { config } = useChart()
|
||||
const config = {}
|
||||
const { t } = useLingui()
|
||||
const totalLabelNode = totalLabel ?? t`Total`
|
||||
const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total`
|
||||
|
||||
React.useMemo(() => {
|
||||
if (filter) {
|
||||
const filterTerms = filter
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((term) => term.length > 0)
|
||||
payload = payload?.filter((item) => {
|
||||
const itemName = (item.name as string)?.toLowerCase()
|
||||
return filterTerms.some((term) => itemName?.includes(term))
|
||||
})
|
||||
}
|
||||
if (itemSorter) {
|
||||
// @ts-expect-error
|
||||
payload?.sort(itemSorter)
|
||||
}
|
||||
}, [itemSorter, payload])
|
||||
|
||||
const totalValueDisplay = React.useMemo(() => {
|
||||
if (!showTotal || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let totalValue = 0
|
||||
let hasNumericValue = false
|
||||
|
||||
for (const item of payload) {
|
||||
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
|
||||
if (Number.isFinite(numericValue)) {
|
||||
totalValue += numericValue
|
||||
hasNumericValue = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNumericValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalKey = "__total__"
|
||||
const totalItem: any = {
|
||||
value: totalValue,
|
||||
name: totalName,
|
||||
dataKey: totalKey,
|
||||
color,
|
||||
}
|
||||
|
||||
if (content) {
|
||||
totalItem.payload = payload[0]?.payload
|
||||
}
|
||||
|
||||
if (typeof formatter === "function") {
|
||||
return formatter(totalValue, totalName, totalItem, payload.length, totalItem.payload ?? payload[0]?.payload)
|
||||
}
|
||||
|
||||
if (content) {
|
||||
return content(totalItem, totalKey)
|
||||
}
|
||||
|
||||
return `${totalValue.toLocaleString()}${unit ?? ""}`
|
||||
}, [color, content, formatter, nameKey, payload, showTotal, totalName, unit])
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value = !labelKey && typeof label === "string" ? label : itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
const nestLabel = false
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-28 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.name || item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-border bg-(--color-bg)", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none gap-2",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
truncate ? "max-w-40 truncate leading-normal -my-1" : ""
|
||||
)}
|
||||
>
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
{item.value !== undefined && (
|
||||
<span className="font-medium text-foreground">
|
||||
{content && typeof content === "function"
|
||||
? content(item, key)
|
||||
: item.value.toLocaleString() + (unit ? unit : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalValueDisplay ? (
|
||||
<>
|
||||
<Separator className="mt-0.5" />
|
||||
<div className="flex items-center justify-between gap-2 -mt-0.75 font-medium">
|
||||
<span className="text-muted-foreground ps-3">{totalLabelNode}</span>
|
||||
<span>{totalValueDisplay}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
reverse?: boolean
|
||||
}
|
||||
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
|
||||
// const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const reversedPayload = reverse ? [...payload].reverse() : payload
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4 gap-y-1 flex-wrap ps-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{reversedPayload.map((item) => {
|
||||
// const key = `${nameKey || item.dataKey || 'value'}`
|
||||
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
// 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground text-muted-foreground'
|
||||
"flex items-center gap-1.5 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{/* {itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : ( */}
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
{item.value}
|
||||
{/* )} */}
|
||||
{/* {itemConfig?.label} */}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
let cachedAxis: JSX.Element
|
||||
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
||||
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
||||
return cachedAxis
|
||||
}
|
||||
cachedAxis = (
|
||||
<RechartsPrimitive.XAxis
|
||||
dataKey="created"
|
||||
domain={domain}
|
||||
ticks={ticks}
|
||||
allowDataOverflow
|
||||
type="number"
|
||||
scale="time"
|
||||
minTickGap={12}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
)
|
||||
return cachedAxis
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
xAxis,
|
||||
// ChartStyle,
|
||||
}
|
||||
|
||||
export function pinnedAxisDomain(): AxisDomain {
|
||||
return [
|
||||
0,
|
||||
(dataMax: number) => {
|
||||
if (dataMax > 10) {
|
||||
return Math.round(dataMax)
|
||||
}
|
||||
if (dataMax > 1) {
|
||||
return Math.round(dataMax / 0.1) * 0.1
|
||||
}
|
||||
return dataMax
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="size-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChevronDownIcon, HourglassIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "./button"
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Collapsible({ title, children, description, defaultOpen = false, className, icon }: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className={cn("border rounded-lg", className)}>
|
||||
<Button variant="ghost" className="w-full justify-between p-4 font-semibold" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn("h-4 w-4 transition-transform duration-200", {
|
||||
"rotate-180": isOpen,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
{description && <div className="px-4 pb-2 text-sm text-muted-foreground">{description}</div>}
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid gap-3">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import type * as React from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn("bg-card flex h-full w-full flex-col overflow-hidden rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />
|
||||
}
|
||||
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent/70 data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-wide", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("grid gap-1.5 text-start", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3.5", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex select-none items-center rounded-sm px-2.5 py-1.5 text-[.95em] outline-hidden focus:bg-accent/70 data-[state=open]:bg-accent/70",
|
||||
inset && "ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ms-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"cursor-pointer relative flex select-none items-center rounded-sm px-2.5 py-1.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
inset && "ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2.5 py-1.5 text-[.95em] font-semibold", inset && "ps-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ms-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
|
||||
export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M231 217a12 12 0 0 1-16-2c-2-1-35-44-35-127a52 52 0 1 0-104 0c0 83-33 126-35 127a12 12 0 0 1-18-14c0-1 29-39 29-113a76 76 0 1 1 152 0c0 74 29 112 29 113a12 12 0 0 1-2 16m-127-97a16 16 0 1 0-16-16 16 16 0 0 0 16 16m64-16a16 16 0 1 0-16 16 16 16 0 0 0 16-16m-73 51 28 12a12 12 0 0 0 10 0l28-12a12 12 0 0 0-10-22l-23 10-23-10a12 12 0 0 0-10 22m33 29a57 57 0 0 0-39 15 12 12 0 0 0 17 18 33 33 0 0 1 44 0 12 12 0 1 0 17-18 57 57 0 0 0-39-15"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// icon park (Apache 2.0) https://github.com/bytedance/IconPark/blob/master/LICENSE
|
||||
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 48 48">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.8"
|
||||
d="m6.8 11 12.9-1.7v12.1h-13zm18-2.2 16.4-2v14.6H25zm0 18.6 16.4.4v13.4L25 38.6zm-18-.8 12.9.3v10.9l-13-2.2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// teenyicons (MIT) https://github.com/teenyicons/teenyicons/blob/master/LICENSE
|
||||
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.1 4.7a5 5 0 0 1 3.8 2c-3.3 1.9-2.8 6.7.6 8L17.2 17c-.8 1.3-2 2.9-3.5 2.9-1.2 0-1.6-.9-3.3-.8s-2.2.8-3.5.8c-1.4 0-2.5-1.5-3.4-2.7-2.3-3.6-2.5-7.9-1.1-10 1-1.7 2.6-2.6 4.1-2.6 1.6 0 2.6.8 3.8.8 1.3 0 2-.8 3.8-.8M13.7 0c.2 1.2-.3 2.4-1 3.2a4 4 0 0 1-3 1.6c-.2-1.2.3-2.3 1-3.2.7-.8 2-1.5 3-1.6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function FreeBsdIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2.7 2C3.5 2 6 3.2 6 3.2 4.8 4 3.7 5 3 6.4 2.1 4.8 1.3 2.9 2 2.2l.7-.2m18.1.1c.4 0 .8 0 1 .2 1 1.1-2 5.8-2.4 6.4-.5.5-1.8 0-2.9-1-1-1.2-1.5-2.4-1-3 .4-.4 3.6-2.4 5.3-2.6m-8.8.5c1.3 0 2.5.2 3.7.7l-1 .7c-1 1-.6 2.8 1 4.4 1 1 2.1 1.6 3 1.6a2 2 0 0 0 1.5-.6l.7-1a9.7 9.7 0 1 1-18.6 3.8A9.7 9.7 0 0 1 12 2.7"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE
|
||||
export function DockerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 512 512" fill="currentColor">
|
||||
<path d="M507 211c-1-1-14-11-42-11a133 133 0 0 0-21 2c-6-36-36-54-37-55l-7-4-5 7a102 102 0 0 0-13 30c-5 21-2 40 8 57-12 7-33 9-37 9H16a16 16 0 0 0-16 16 241 241 0 0 0 15 87c11 30 29 53 51 67 25 15 66 24 113 24a344 344 0 0 0 62-6 257 257 0 0 0 82-29 224 224 0 0 0 55-46c27-30 43-64 55-94h4c30 0 48-12 58-22a63 63 0 0 0 15-22l2-6Z" />
|
||||
<path d="M47 236h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4H47a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m-125-57h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m0-58h45a4 4 0 0 0 4-4V76a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 116h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
||||
export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5 3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h14v4H5zm0 8a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm0 2h14v4H5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
|
||||
export function ChartAverage(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
|
||||
<path strokeWidth="3" d="M4 4v40h40" />
|
||||
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
|
||||
<path strokeWidth="4" d="M10 24h34" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// IconPark Apache License 2.0 https://github.com/bytedance/IconPark
|
||||
export function ChartMax(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 48 48" stroke="currentColor" {...props}>
|
||||
<path strokeWidth="3" d="M4 4v40h40" />
|
||||
<path strokeWidth="3" d="M10 38S15.3 4 27 4s17 34 17 34" />
|
||||
<path strokeWidth="4" d="M10 4h34" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Lucide https://github.com/lucide-icons/lucide (not in package for some reason)
|
||||
export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="2" viewBox="0 0 24 24" {...props}>
|
||||
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3zM6 8v1m4-1v1m4-1v1m4-1v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Phosphor MIT https://github.com/phosphor-icons/core
|
||||
export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
|
||||
<path d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Huge icons (MIT)
|
||||
export function GpuIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" fill="none" strokeWidth="2">
|
||||
<path d="M4 21V4.1a1.5 1.5 0 0 0-1.1-1L2 3m2 2h13c2.4 0 3.5 0 4.3.7s.7 2 .7 4.3v4.5c0 2.4 0 3.5-.7 4.3-.8.7-2 .7-4.3.7h-4.9a1.8 1.8 0 0 1-1.6-1c-.3-.6-1-1-1.6-1H4" />
|
||||
<path d="M19 11.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-11.5-3h2m-2 3h2m-2 3h2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Remix icons (Apache 2.0) https://github.com/Remix-Design/RemixIcon/blob/master/License
|
||||
export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||
<path d="M4 2h16v4.5L13.5 12l6.5 5.5V22H4v-4.5l6.5-5.5L4 6.5zm12.3 5L18 5.5V4H6v1.5L7.7 7zM12 13.3l-6 5.2V20h1l5-3 5 3h1v-1.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
|
||||
<title>WebSocket</title>
|
||||
<path d="M192 145h32V68l-36-35-22 22 26 27zm32 16H113l-26-27 11-11 22 22h45l-44-45 11-11 44 44V88l-21-22 11-11-55-55H0l32 32h65l24 23-34 34-24-23V48H32v31l55 55-23 22 36 36h156z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||
<path d="M16 13H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||
<path d="M16 17H8V6h8m.7-2H15V2H9v2H7.3A1.3 1.3 0 0 0 6 5.3v15.4q.1 1.2 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3V5.3q-.1-1.2-1.3-1.3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function BatteryHighIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||
<path d="M16 9H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||
export function BatteryFullIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||
<path d="M16.67 4H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// https://github.com/phosphor-icons/core (MIT license)
|
||||
export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
|
||||
<path d="M224,48H180V16a12,12,0,0,0-24,0V48H100V16a12,12,0,0,0-24,0V48H32.55C24.4,48,20,54.18,20,60A12,12,0,0,0,32,72H44v92a44.05,44.05,0,0,0,44,44h28v32a12,12,0,0,0,24,0V208h28a44.05,44.05,0,0,0,44-44V72h12a12,12,0,0,0,0-24ZM188,164a20,20,0,0,1-20,20H88a20,20,0,0,1-20-20V72H188Zm-85.86-29.17a12,12,0,0,1-1.38-11l12-32a12,12,0,1,1,22.48,8.42L129.32,116H144a12,12,0,0,1,11.24,16.21l-12,32a12,12,0,0,1-22.48-8.42L126.68,140H112A12,12,0,0,1,102.14,134.83Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Lucide Icons (ISC) - used for ports
|
||||
export function SquareArrowRightEnterIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}>
|
||||
<path d="m10 16 4-4-4-4" />
|
||||
<path d="M3 12h11" />
|
||||
<path d="M3 8V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { CopyIcon } from "lucide-react"
|
||||
import { copyToClipboard } from "@/lib/utils"
|
||||
import { Button } from "./button"
|
||||
import { Input } from "./input"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
||||
|
||||
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-linear-to-r rtl:bg-linear-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
|
||||
}
|
||||
></div>
|
||||
<Tooltip disableHoverableContent={true}>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InputProps } from "./input"
|
||||
|
||||
type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
|
||||
value: string[]
|
||||
onChange: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
|
||||
({ className, value, onChange, ...props }, ref) => {
|
||||
const [pendingDataPoint, setPendingDataPoint] = React.useState("")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pendingDataPoint.includes(",")) {
|
||||
const newDataPoints = new Set([...value, ...pendingDataPoint.split(",").map((chunk) => chunk.trim())])
|
||||
onChange(Array.from(newDataPoints))
|
||||
setPendingDataPoint("")
|
||||
}
|
||||
}, [pendingDataPoint, onChange, value])
|
||||
|
||||
const addPendingDataPoint = () => {
|
||||
if (pendingDataPoint) {
|
||||
const newDataPoints = new Set([...value, pendingDataPoint])
|
||||
onChange(Array.from(newDataPoints))
|
||||
setPendingDataPoint("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background min-h-10 flex w-full flex-wrap gap-2 rounded-md border px-3 py-2 text-sm placeholder:text-muted-foreground has-focus-visible:outline-hidden ring-offset-background has-focus-visible:ring-2 has-focus-visible:ring-ring has-focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value.map((item) => (
|
||||
<Badge key={item}>
|
||||
{item}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ms-2 h-3 w-3"
|
||||
onClick={() => {
|
||||
onChange(value.filter((i) => i !== item))
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 outline-hidden bg-background placeholder:text-muted-foreground"
|
||||
value={pendingDataPoint}
|
||||
onChange={(e) => setPendingDataPoint(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault()
|
||||
addPendingDataPoint()
|
||||
} else if (e.key === "Backspace" && pendingDataPoint.length === 0 && value.length > 0) {
|
||||
e.preventDefault()
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
InputTags.displayName = "InputTags"
|
||||
|
||||
export { InputTags }
|
||||
@@ -0,0 +1,20 @@
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import type * as React from "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 }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export default Slider
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:rtl:-translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("bg-table-header border-b border-border/50 [&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium last:[&>tr]:border-b-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
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 cursor-pointer hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-14 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-dvh w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pe-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({ delayDuration = 50, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground border animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow
|
||||
className="bg-popover border z-50 fill-popover size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] will-change-transform"
|
||||
style={{ clipPath: "inset(25% 0 0 25%)" }}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,187 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
Reference in New Issue
Block a user