mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 10:46:16 +01:00
migrate to lingui
This commit is contained in:
@@ -19,11 +19,9 @@ import { useState, useRef, MutableRefObject } from "react"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
|
||||
import { navigate } from "./router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans } from "@lingui/macro"
|
||||
|
||||
export function AddSystemButton({ className }: { className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const port = useRef() as MutableRefObject<HTMLInputElement>
|
||||
const publicKey = useStore($publicKey)
|
||||
@@ -73,56 +71,64 @@ export function AddSystemButton({ className }: { className?: string }) {
|
||||
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
{t("add")}
|
||||
<span className="hidden sm:inline">{t("system")}</span>
|
||||
<Trans>
|
||||
Add <span className="hidden sm:inline">System</span>
|
||||
</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
|
||||
<Tabs defaultValue="docker">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-2">{t("add_system.add_new_system")}</DialogTitle>
|
||||
<DialogTitle className="mb-2">
|
||||
<Trans>Add New System</Trans>
|
||||
</DialogTitle>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||
<TabsTrigger value="binary">{t("add_system.binary")}</TabsTrigger>
|
||||
<TabsTrigger value="binary">
|
||||
<Trans>Binary</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</DialogHeader>
|
||||
{/* Docker */}
|
||||
<TabsContent value="docker">
|
||||
<DialogDescription className={"mb-4"}>
|
||||
{t("add_system.dialog_des_1")} <code className="bg-muted px-1 rounded-sm">docker-compose.yml</code>{" "}
|
||||
{t("add_system.dialog_des_2")}
|
||||
<DialogDescription className="mb-4 leading-normal">
|
||||
<Trans>
|
||||
The agent must be running on the system to connect. Copy the
|
||||
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</TabsContent>
|
||||
{/* Binary */}
|
||||
<TabsContent value="binary">
|
||||
<DialogDescription className={"mb-4"}>
|
||||
{t("add_system.dialog_des_1")} <code className="bg-muted px-1 rounded-sm">install command</code>{" "}
|
||||
{t("add_system.dialog_des_2")}
|
||||
<DialogDescription className="mb-4 leading-normal">
|
||||
<Trans>
|
||||
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</TabsContent>
|
||||
<form onSubmit={handleSubmit as any}>
|
||||
<div className="grid gap-3 mt-1 mb-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-end">
|
||||
{t("add_system.name")}
|
||||
<Trans>Name</Trans>
|
||||
</Label>
|
||||
<Input id="name" name="name" className="col-span-3" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="host" className="text-end">
|
||||
{t("add_system.host_ip")}
|
||||
<Trans>Host / IP</Trans>
|
||||
</Label>
|
||||
<Input id="host" name="host" className="col-span-3" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="port" className="text-end">
|
||||
{t("add_system.port")}
|
||||
<Trans>Port</Trans>
|
||||
</Label>
|
||||
<Input ref={port} name="port" id="port" defaultValue="45876" className="col-span-3" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4 relative">
|
||||
<Label htmlFor="pkey" className="text-end whitespace-pre">
|
||||
{t("add_system.key")}
|
||||
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
||||
</Label>
|
||||
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
|
||||
<div
|
||||
@@ -143,7 +149,9 @@ export function AddSystemButton({ className }: { className?: string }) {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("add_system.click_to_copy")}</p>
|
||||
<p>
|
||||
<Trans>Click to copy</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -153,18 +161,22 @@ export function AddSystemButton({ className }: { className?: string }) {
|
||||
<TabsContent value="docker">
|
||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
||||
<Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
|
||||
{t("copy")} docker compose
|
||||
<Trans>Copy</Trans> docker compose
|
||||
</Button>
|
||||
<Button>
|
||||
<Trans>Add system</Trans>
|
||||
</Button>
|
||||
<Button>{t("add_system.add_system")}</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
{/* Binary */}
|
||||
<TabsContent value="binary">
|
||||
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
||||
<Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
|
||||
{t("copy")} linux {t("add_system.command")}
|
||||
<Trans>Copy Linux command</Trans>
|
||||
</Button>
|
||||
<Button>
|
||||
<Trans>Add system</Trans>
|
||||
</Button>
|
||||
<Button>{t("add_system.add_system")}</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Link } from "../router"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||
const alerts = useStore($alerts)
|
||||
@@ -29,7 +29,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Alerts" data-nolink onClick={() => setOpened(true)}>
|
||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||
<BellIcon
|
||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||
"fill-primary": active,
|
||||
@@ -49,8 +49,6 @@ function TheContent({
|
||||
}: {
|
||||
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||
const systems = $systems.get()
|
||||
|
||||
@@ -66,13 +64,17 @@ function TheContent({
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">{t("alerts.title")}</DialogTitle>
|
||||
<DialogTitle className="text-xl">
|
||||
<Trans>Alerts</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("alerts.subtitle_1")}{" "}
|
||||
<Link href="/settings/notifications" className="link">
|
||||
{t("alerts.notification_settings")}
|
||||
</Link>{" "}
|
||||
{t("alerts.subtitle_2")}
|
||||
<Trans>
|
||||
See{" "}
|
||||
<Link href="/settings/notifications" className="link">
|
||||
notification settings
|
||||
</Link>{" "}
|
||||
to configure how you receive alerts.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="system">
|
||||
@@ -83,7 +85,7 @@ function TheContent({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="global">
|
||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||
{t("all_systems")}
|
||||
<Trans>All Systems</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="system">
|
||||
@@ -104,7 +106,7 @@ function TheContent({
|
||||
checked={overwriteExisting}
|
||||
onCheckedChange={setOverwriteExisting}
|
||||
/>
|
||||
{t("alerts.overwrite_existing_alerts")}
|
||||
<Trans>Overwrite existing alerts</Trans>
|
||||
</label>
|
||||
<div className="grid gap-3">
|
||||
{data.map((d) => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { lazy, Suspense, useRef, useState } from "react"
|
||||
import { toast } from "../ui/use-toast"
|
||||
import { RecordOptions } from "pocketbase"
|
||||
import { newQueue, Queue } from "@henrygd/queue"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t, Plural } from "@lingui/macro"
|
||||
|
||||
interface AlertData {
|
||||
checked?: boolean
|
||||
@@ -24,8 +24,8 @@ let queue: Queue
|
||||
|
||||
const failedUpdateToast = () =>
|
||||
toast({
|
||||
title: "Failed to update alert",
|
||||
description: "Please check logs for more details.",
|
||||
title: t`Failed to update alert`,
|
||||
description: t`Please check logs for more details.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
|
||||
@@ -156,8 +156,6 @@ export function SystemAlertGlobal({
|
||||
}
|
||||
|
||||
function AlertContent({ data }: { data: AlertData }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { key } = data
|
||||
|
||||
const hasSliders = !("single" in data.alert)
|
||||
@@ -185,9 +183,9 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
>
|
||||
<div className="grid gap-1 select-none">
|
||||
<p className="font-semibold flex gap-3 items-center">
|
||||
<Icon className="h-4 w-4 opacity-85" /> {t(data.alert.name)}
|
||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
||||
</p>
|
||||
{!showSliders && <span className="block text-sm text-muted-foreground">{t(data.alert.desc)}</span>}
|
||||
{!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
id={`s${key}`}
|
||||
@@ -203,11 +201,13 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
<Suspense fallback={<div className="h-10" />}>
|
||||
<div>
|
||||
<p id={`v${key}`} className="text-sm block h-8">
|
||||
{t("alerts.average_exceeds")}{" "}
|
||||
<strong className="text-foreground">
|
||||
{value}
|
||||
{data.alert.unit}
|
||||
</strong>
|
||||
<Trans>
|
||||
Average exceeds{" "}
|
||||
<strong className="text-foreground">
|
||||
{value}
|
||||
{data.alert.unit}
|
||||
</strong>
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Slider
|
||||
@@ -222,10 +222,10 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
</div>
|
||||
<div>
|
||||
<p id={`t${key}`} className="text-sm block h-8">
|
||||
{t("alerts.for")} <strong className="text-foreground">{min}</strong>{" "}
|
||||
{t("minutes", {
|
||||
count: min,
|
||||
}).replace(String(min), "")}
|
||||
<Trans>
|
||||
For <strong className="text-foreground">{min}</strong>{" "}
|
||||
<Plural value={min} one=" minute" other=" minutes" />
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Slider
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
// import Spinner from '../spinner'
|
||||
import { ChartData } from "@/types"
|
||||
import { memo, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
/** [label, key, color, opacity] */
|
||||
type DataKeys = [string, string, number, number]
|
||||
@@ -39,7 +40,7 @@ export default memo(function AreaChartDefault({
|
||||
chartData: ChartData
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { t } = useTranslation()
|
||||
const { i18n } = useLingui()
|
||||
|
||||
const { chartTime } = chartData
|
||||
|
||||
@@ -47,26 +48,26 @@ export default memo(function AreaChartDefault({
|
||||
|
||||
const dataKeys: DataKeys[] = useMemo(() => {
|
||||
// [label, key, color, opacity]
|
||||
if (chartName === t("alerts.info.cpu_usage")) {
|
||||
return [[chartName, "cpu", 1, 0.4]]
|
||||
if (chartName === "CPU Usage") {
|
||||
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
||||
} else if (chartName === "dio") {
|
||||
return [
|
||||
[t("monitor.write"), "dw", 3, 0.3],
|
||||
[t("monitor.read"), "dr", 1, 0.3],
|
||||
[t({ message: "Write", comment: "Context is disk write" }), "dw", 3, 0.3],
|
||||
[t({ message: "Read", comment: "Context is disk read" }), "dr", 1, 0.3],
|
||||
]
|
||||
} else if (chartName === "bw") {
|
||||
return [
|
||||
[t("monitor.sent"), "ns", 5, 0.2],
|
||||
[t("monitor.received"), "nr", 2, 0.2],
|
||||
[t({ message: "Sent", comment: "Context is network bytes sent (upload)" }), "ns", 5, 0.2],
|
||||
[t({ message: "Received", comment: "Context is network bytes received (download)" }), "nr", 2, 0.2],
|
||||
]
|
||||
} else if (chartName.startsWith("efs")) {
|
||||
return [
|
||||
[t("monitor.write"), `${chartName}.w`, 3, 0.3],
|
||||
[t("monitor.read"), `${chartName}.r`, 1, 0.3],
|
||||
[t`Read`, `${chartName}.w`, 3, 0.3],
|
||||
[t`Write`, `${chartName}.r`, 1, 0.3],
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [t])
|
||||
}, [chartName, i18n.locale])
|
||||
|
||||
// console.log('Rendered at', new Date())
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
export default memo(function DiskChart({
|
||||
dataKey,
|
||||
@@ -24,7 +25,7 @@ export default memo(function DiskChart({
|
||||
chartData: ChartData
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { t } = useTranslation()
|
||||
const { _ } = useLingui()
|
||||
|
||||
if (chartData.systemStats.length === 0) {
|
||||
return null
|
||||
@@ -70,7 +71,7 @@ export default memo(function DiskChart({
|
||||
/>
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
name={t("alerts.info.disk_usage")}
|
||||
name={_(t`Disk Usage`)}
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-4))"
|
||||
fillOpacity={0.4}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/comp
|
||||
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
|
||||
import { memo } from "react"
|
||||
import { ChartData } from "@/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
const { t } = useTranslation()
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { _ } = useLingui()
|
||||
|
||||
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
|
||||
|
||||
@@ -61,7 +62,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
name={t("monitor.used")}
|
||||
name={_(t`Used`)}
|
||||
order={3}
|
||||
dataKey="stats.mu"
|
||||
type="monotoneX"
|
||||
@@ -85,7 +86,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
name={t("monitor.cache_buffers")}
|
||||
name={_(t`Cache / Buffers`)}
|
||||
order={1}
|
||||
dataKey="stats.mb"
|
||||
type="monotoneX"
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
} from "@/lib/utils"
|
||||
import { ChartData } from "@/types"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "@lingui/macro"
|
||||
|
||||
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (chartData.systemStats.length === 0) {
|
||||
return null
|
||||
@@ -54,7 +53,7 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
|
||||
/>
|
||||
<Area
|
||||
dataKey="stats.su"
|
||||
name={t("monitor.used")}
|
||||
name={t`Used`}
|
||||
type="monotoneX"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.4}
|
||||
|
||||
@@ -25,10 +25,9 @@ import { useStore } from "@nanostores/react"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import { isAdmin } from "@/lib/utils"
|
||||
import { navigate } from "./router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const systems = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,9 +44,11 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t("command.search")} />
|
||||
<CommandInput placeholder={t`Search for systems or settings...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
{systems.length > 0 && (
|
||||
<>
|
||||
<CommandGroup>
|
||||
@@ -68,7 +69,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
<CommandSeparator className="mb-1.5" />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("command.pages_settings")}>
|
||||
<CommandGroup heading={t`Pages / Settings`}>
|
||||
<CommandItem
|
||||
keywords={["home"]}
|
||||
onSelect={() => {
|
||||
@@ -77,8 +78,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard className="me-2 h-4 w-4" />
|
||||
<span>{t("command.dashboard")}</span>
|
||||
<CommandShortcut>{t("command.page")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Dashboard</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Page</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -87,8 +92,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("settings.settings")}</span>
|
||||
<CommandShortcut>{t("settings.settings")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Settings</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Settings</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["alerts"]}
|
||||
@@ -98,8 +107,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<MailIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("settings.notifications.title")}</span>
|
||||
<CommandShortcut>{t("settings.settings")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Notifications</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Settings</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["github"]}
|
||||
@@ -108,14 +121,16 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<Github className="me-2 h-4 w-4" />
|
||||
<span>{t("command.documentation")}</span>
|
||||
<span>
|
||||
<Trans>Documentation</Trans>
|
||||
</span>
|
||||
<CommandShortcut>GitHub</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<CommandSeparator className="mb-1.5" />
|
||||
<CommandGroup heading={t("command.admin")}>
|
||||
<CommandGroup heading={t`Admin`}>
|
||||
<CommandItem
|
||||
keywords={["pocketbase"]}
|
||||
onSelect={() => {
|
||||
@@ -124,8 +139,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<UsersIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("user_dm.users")}</span>
|
||||
<CommandShortcut>{t("command.admin")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -134,8 +153,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<LogsIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("user_dm.logs")}</span>
|
||||
<CommandShortcut>{t("command.admin")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Logs</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -144,8 +167,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("user_dm.backups")}</span>
|
||||
<CommandShortcut>{t("command.admin")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Backups</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["oauth", "oicd"]}
|
||||
@@ -155,8 +182,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<LockKeyholeIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("user_dm.auth_providers")}</span>
|
||||
<CommandShortcut>{t("command.admin")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>Auth Providers</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={["email"]}
|
||||
@@ -166,8 +197,12 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
|
||||
}}
|
||||
>
|
||||
<MailIcon className="me-2 h-4 w-4" />
|
||||
<span>{t("command.SMTP_settings")}</span>
|
||||
<CommandShortcut>{t("command.admin")}</CommandShortcut>
|
||||
<span>
|
||||
<Trans>SMTP settings</Trans>
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
<Trans>Admin</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
|
||||
@@ -2,17 +2,19 @@ import { useEffect, useMemo, useRef } from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { $copyContent } from "@/lib/stores"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans } from "@lingui/macro"
|
||||
|
||||
export default function CopyToClipboard({ content }: { content: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog defaultOpen={true}>
|
||||
<DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("clipboard.title")}</DialogTitle>
|
||||
<DialogDescription className="hidden xs:block">{t("clipboard.des")}</DialogDescription>
|
||||
<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>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useEffect } from "react"
|
||||
import { LanguagesIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import languages from "../lib/languages.json"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { setLang } from "@/lib/i18n"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { dynamicActivate } from "@/lib/i18n"
|
||||
|
||||
export function LangToggle() {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language
|
||||
}, [i18n.language])
|
||||
const { i18n } = useLingui()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -27,8 +22,8 @@ export function LangToggle() {
|
||||
{languages.map(({ lang, label, e }) => (
|
||||
<DropdownMenuItem
|
||||
key={lang}
|
||||
className={cn("px-3 flex gap-2.5", lang === i18n.language ? "font-bold" : "")}
|
||||
onClick={() => setLang(lang)}
|
||||
className={cn("px-3 flex gap-2.5", lang === i18n.locale && "font-semibold")}
|
||||
onClick={() => dynamicActivate(lang)}
|
||||
>
|
||||
<span>{e}</span> {label}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -10,11 +10,11 @@ import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from
|
||||
import { useCallback, useState } from "react"
|
||||
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
|
||||
import { Link } from "../router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
const honeypot = v.literal("")
|
||||
const emailSchema = v.pipe(v.string(), v.email("Invalid email address."))
|
||||
const passwordSchema = v.pipe(v.string(), v.minLength(10, "Password must be at least 10 characters."))
|
||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
||||
const passwordSchema = v.pipe(v.string(), v.minLength(10, t`Password must be at least 10 characters.`))
|
||||
|
||||
const LoginSchema = v.looseObject({
|
||||
name: honeypot,
|
||||
@@ -39,8 +39,8 @@ const RegisterSchema = v.looseObject({
|
||||
|
||||
const showLoginFaliedToast = () => {
|
||||
toast({
|
||||
title: "Login attempt failed",
|
||||
description: "Please check your credentials and try again",
|
||||
title: t`Login attempt failed`,
|
||||
description: t`Please check your credentials and try again`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
@@ -55,8 +55,6 @@ export function UserAuthForm({
|
||||
isFirstRun: boolean
|
||||
authMethods: AuthMethodsList
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [isOauthLoading, setIsOauthLoading] = useState<boolean>(false)
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
|
||||
@@ -130,14 +128,14 @@ export function UserAuthForm({
|
||||
<div className="grid gap-1 relative">
|
||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Label className="sr-only" htmlFor="username">
|
||||
Username
|
||||
<Trans>Username</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
placeholder="username"
|
||||
placeholder={t`username`}
|
||||
type="username"
|
||||
autoCapitalize="none"
|
||||
autoComplete="username"
|
||||
@@ -151,13 +149,13 @@ export function UserAuthForm({
|
||||
<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">
|
||||
Email
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder={isFirstRun ? "email" : "name@example.com"}
|
||||
placeholder={isFirstRun ? t`email` : t`name@example.com`}
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
@@ -170,17 +168,17 @@ export function UserAuthForm({
|
||||
<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">
|
||||
Password
|
||||
<Trans>Password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="pass"
|
||||
name="password"
|
||||
placeholder="password"
|
||||
placeholder={t`Password`}
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading || isOauthLoading}
|
||||
className="ps-9"
|
||||
className="ps-9 lowercase"
|
||||
/>
|
||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||
</div>
|
||||
@@ -188,17 +186,17 @@ export function UserAuthForm({
|
||||
<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">
|
||||
Confirm password
|
||||
<Trans>Confirm password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="pass2"
|
||||
name="passwordConfirm"
|
||||
placeholder="confirm password"
|
||||
placeholder={t`Confirm password`}
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading || isOauthLoading}
|
||||
className="ps-9"
|
||||
className="ps-9 lowercase"
|
||||
/>
|
||||
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
||||
</div>
|
||||
@@ -214,7 +212,7 @@ export function UserAuthForm({
|
||||
) : (
|
||||
<LogInIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
{isFirstRun ? t("auth.create_account") : t("auth.sign_in")}
|
||||
{isFirstRun ? t`Create account` : t`Sign in`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -225,7 +223,9 @@ export function UserAuthForm({
|
||||
<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">Or continue with</span>
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -253,8 +253,8 @@ export function UserAuthForm({
|
||||
if (!authWindow) {
|
||||
setIsOauthLoading(false)
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please enable pop-ups for this site",
|
||||
title: t`Error`,
|
||||
description: t`Please enable pop-ups for this site`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
@@ -304,19 +304,25 @@ export function UserAuthForm({
|
||||
</DialogTrigger>
|
||||
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>OAuth 2 / OIDC support</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>OAuth 2 / OIDC support</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-primary/70 text-[0.95em] contents">
|
||||
<p>{t("auth.openid_des")}</p>
|
||||
<p>
|
||||
{t("please_view_the")}{" "}
|
||||
<a
|
||||
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
||||
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
||||
>
|
||||
GitHub README
|
||||
</a>{" "}
|
||||
{t("for_instructions")}
|
||||
<Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>
|
||||
Please see{" "}
|
||||
<a
|
||||
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
||||
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
||||
>
|
||||
the documentation
|
||||
</a>{" "}
|
||||
for instructions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -328,7 +334,7 @@ export function UserAuthForm({
|
||||
href="/forgot-password"
|
||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{t("auth.forgot_password")}
|
||||
<Trans>Forgot password?</Trans>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,18 +8,17 @@ import { cn } from "@/lib/utils"
|
||||
import { pb } from "@/lib/stores"
|
||||
import { Dialog, DialogHeader } from "../ui/dialog"
|
||||
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
|
||||
const showLoginFaliedToast = () => {
|
||||
toast({
|
||||
title: "Login attempt failed",
|
||||
description: "Please check your credentials and try again",
|
||||
title: t`Login attempt failed`,
|
||||
description: t`Please check your credentials and try again`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [email, setEmail] = useState("")
|
||||
|
||||
@@ -31,8 +30,8 @@ export default function ForgotPassword() {
|
||||
// console.log(email)
|
||||
await pb.collection("users").requestPasswordReset(email)
|
||||
toast({
|
||||
title: "Password reset request received",
|
||||
description: `Check ${email} for a reset link.`,
|
||||
title: t`Password reset request received`,
|
||||
description: t`Check ${email} for a reset link.`,
|
||||
})
|
||||
} catch (e) {
|
||||
showLoginFaliedToast()
|
||||
@@ -51,7 +50,7 @@ export default function ForgotPassword() {
|
||||
<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">
|
||||
Email
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={email}
|
||||
@@ -74,22 +73,30 @@ export default function ForgotPassword() {
|
||||
) : (
|
||||
<SendHorizonalIcon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
{t("auth.reset_password")}
|
||||
<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">
|
||||
{t("auth.command_line_instructions")}
|
||||
<Trans>Command line instructions</Trans>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[33em]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.command_line_instructions")}</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Command line instructions</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">{t("auth.command_1")}</p>
|
||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">{t("auth.command_2")}</p>
|
||||
<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 admin update youremail@example.com newpassword
|
||||
</code>
|
||||
|
||||
@@ -6,17 +6,15 @@ import { useStore } from "@nanostores/react"
|
||||
import ForgotPassword from "./forgot-pass-form"
|
||||
import { $router } from "../router"
|
||||
import { AuthMethodsList } from "pocketbase"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "@lingui/macro"
|
||||
|
||||
export default function () {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const page = useStore($router)
|
||||
const [isFirstRun, setFirstRun] = useState(false)
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Login / Beszel"
|
||||
document.title = t`Login` + " / Beszel"
|
||||
|
||||
pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
|
||||
setFirstRun(firstRun)
|
||||
@@ -33,11 +31,11 @@ export default function () {
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isFirstRun) {
|
||||
return t("auth.create")
|
||||
return t`Please create an admin account`
|
||||
} else if (page?.path === "/forgot-password") {
|
||||
return t("auth.reset")
|
||||
return t`Enter email address to reset password`
|
||||
} else {
|
||||
return t("auth.login")
|
||||
return t`Please sign in to your account`
|
||||
}
|
||||
}, [isFirstRun, page])
|
||||
|
||||
|
||||
@@ -3,38 +3,36 @@ import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const options = [
|
||||
{
|
||||
theme: "light",
|
||||
Icon: SunIcon,
|
||||
label: t("themes.light"),
|
||||
label: <Trans comment="Light theme">Light</Trans>,
|
||||
},
|
||||
{
|
||||
theme: "dark",
|
||||
Icon: MoonStarIcon,
|
||||
label: t("themes.dark"),
|
||||
label: <Trans comment="Dark theme">Dark</Trans>,
|
||||
},
|
||||
{
|
||||
theme: "system",
|
||||
Icon: LaptopIcon,
|
||||
label: t("themes.system"),
|
||||
label: <Trans comment="System theme">System</Trans>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={"ghost"} size="icon">
|
||||
<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
||||
<span className="sr-only">{t("themes.toggle_theme")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
|
||||
@@ -27,21 +27,19 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { AddSystemButton } from "./add-system"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TFunction } from "i18next"
|
||||
import { Trans } from "@lingui/macro"
|
||||
|
||||
const CommandPalette = lazy(() => import("./command-palette"))
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||
|
||||
export default function Navbar() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border bt-0 rounded-md my-4">
|
||||
<Link href="/" aria-label="Home" className="p-2 ps-0 me-3">
|
||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||
</Link>
|
||||
<SearchButton t={t} />
|
||||
<SearchButton />
|
||||
|
||||
<div className="flex items-center ms-auto">
|
||||
<LangToggle />
|
||||
@@ -68,31 +66,41 @@ export default function Navbar() {
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/_/" target="_blank">
|
||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("user_dm.users")}</span>
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/_/#/collections?collectionId=2hz5ncl8tizk5nx" target="_blank">
|
||||
<ServerIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("systems")}</span>
|
||||
<span>
|
||||
<Trans>Systems</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/_/#/logs" target="_blank">
|
||||
<LogsIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("user_dm.logs")}</span>
|
||||
<span>
|
||||
<Trans>Logs</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/_/#/settings/backups" target="_blank">
|
||||
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("user_dm.backups")}</span>
|
||||
<span>
|
||||
<Trans>Backups</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/_/#/settings/auth-providers" target="_blank">
|
||||
<LockKeyholeIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("user_dm.auth_providers")}</span>
|
||||
<span>
|
||||
<Trans>Auth Providers</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -101,7 +109,9 @@ export default function Navbar() {
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
|
||||
<LogOutIcon className="me-2.5 h-4 w-4" />
|
||||
<span>{t("user_dm.log_out")}</span>
|
||||
<span>
|
||||
<Trans>Log Out</Trans>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -111,7 +121,7 @@ export default function Navbar() {
|
||||
)
|
||||
}
|
||||
|
||||
function SearchButton({ t }: { t: TFunction<"translation", undefined> }) {
|
||||
function SearchButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const Kbd = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -129,8 +139,7 @@ function SearchButton({ t }: { t: TFunction<"translation", undefined> }) {
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<SearchIcon className="me-1.5 h-4 w-4" />
|
||||
{t("search")}
|
||||
<span className="sr-only">{t("search")}</span>
|
||||
<Trans>Search</Trans>
|
||||
<span className="flex items-center ms-3.5">
|
||||
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
|
||||
@@ -9,17 +9,17 @@ import { AlertRecord, SystemRecord } from "@/types"
|
||||
import { Input } from "../ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Link } from "../router"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Plural, t, Trans } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||
|
||||
export default function () {
|
||||
const { t } = useTranslation()
|
||||
|
||||
export default function Home() {
|
||||
const hubVersion = useStore($hubVersion)
|
||||
const [filter, setFilter] = useState<string>()
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($systems)
|
||||
const { _ } = useLingui()
|
||||
|
||||
// todo: maybe remove active alert if changed
|
||||
const activeAlerts = useMemo(() => {
|
||||
@@ -35,7 +35,7 @@ export default function () {
|
||||
}, [alerts])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Dashboard / Beszel"
|
||||
document.title = t`Dashboard` + " / Beszel"
|
||||
|
||||
// make sure we have the latest list of systems
|
||||
updateSystemList()
|
||||
@@ -61,7 +61,9 @@ export default function () {
|
||||
<Card className="mb-4">
|
||||
<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>{t("home.active_alerts")}</CardTitle>
|
||||
<CardTitle>
|
||||
<Trans>Active Alerts</Trans>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-sm:p-2">
|
||||
@@ -76,16 +78,13 @@ export default function () {
|
||||
>
|
||||
<info.icon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{alert.sysname} {t(info.name)}
|
||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("home.active_des", {
|
||||
value: alert.value,
|
||||
unit: info.unit,
|
||||
})}
|
||||
{t("minutes", {
|
||||
count: alert.min,
|
||||
})}
|
||||
<Trans>
|
||||
Exceeds {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href={`/system/${encodeURIComponent(alert.sysname!)}`}
|
||||
@@ -104,11 +103,15 @@ export default function () {
|
||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||
<div className="grid md:flex gap-5 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2.5">{t("all_systems")}</CardTitle>
|
||||
<CardDescription>{t("home.subtitle")}</CardDescription>
|
||||
<CardTitle className="mb-2.5">
|
||||
<Trans>All Systems</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("filter")}
|
||||
placeholder={_(t`Filter...`)}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full md:w-56 lg:w-72 ms-auto px-4"
|
||||
/>
|
||||
|
||||
@@ -10,11 +10,9 @@ import { useState } from "react"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import clsx from "clsx"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
export default function ConfigYaml() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [configContent, setConfigContent] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
@@ -27,7 +25,7 @@ export default function ConfigYaml() {
|
||||
setConfigContent(config)
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t`Error`,
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
@@ -43,23 +41,37 @@ export default function ConfigYaml() {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">{t("settings.yaml_config.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t("settings.yaml_config.subtitle")}</p>
|
||||
<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">
|
||||
{t("settings.yaml_config.des_1")} <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code>{" "}
|
||||
{t("settings.yaml_config.des_2")}
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t("settings.yaml_config.des_3")}</p>
|
||||
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
||||
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
||||
<AlertTitle>{t("settings.yaml_config.alert.title")}</AlertTitle>
|
||||
<AlertTitle>
|
||||
<Trans>Caution - potential data loss</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{t("settings.yaml_config.alert.des_1")} <code>config.yml</code> {t("settings.yaml_config.alert.des_2")}
|
||||
<Trans>
|
||||
Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
|
||||
</Trans>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -78,7 +90,7 @@ export default function ConfigYaml() {
|
||||
<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")} />
|
||||
{t("settings.export_configuration")}
|
||||
<Trans>Export configuration</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,20 +6,16 @@ import { Separator } from "@/components/ui/separator"
|
||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||
import { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import { useState, useEffect } from "react"
|
||||
// import { Input } from '@/components/ui/input'
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useState } from "react"
|
||||
import { Trans } from "@lingui/macro"
|
||||
import languages from "../../../lib/languages.json"
|
||||
import { setLang } from "@/lib/i18n"
|
||||
import { dynamicActivate } from "@/lib/i18n"
|
||||
import { useLingui } from "@lingui/react"
|
||||
// import { setLang } from "@/lib/i18n"
|
||||
|
||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language
|
||||
}, [i18n.language])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { i18n } = useLingui()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
@@ -33,8 +29,12 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">{t("settings.general.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t("settings.general.subtitle")}</p>
|
||||
<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">
|
||||
@@ -42,25 +42,22 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
||||
<LanguagesIcon className="h-4 w-4" />
|
||||
{t("settings.general.language.title")}
|
||||
<Trans>Language</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("settings.general.language.subtitle_1")}{" "}
|
||||
<a
|
||||
href="https://hosted.weblate.org/engage/beszel/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Weblate
|
||||
</a>{" "}
|
||||
{t("settings.general.language.subtitle_2")}
|
||||
<Trans>
|
||||
Want to help us make our translations even better? Check out{" "}
|
||||
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
||||
Crowdin
|
||||
</a>{" "}
|
||||
for more details.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Label className="block" htmlFor="lang">
|
||||
{t("settings.general.language.preferred_language")}
|
||||
<Trans>Preferred Language</Trans>
|
||||
</Label>
|
||||
<Select value={i18n.language} onValueChange={(lang: string) => setLang(lang)}>
|
||||
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
||||
<SelectTrigger id="lang">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -77,13 +74,15 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium">{t("settings.general.chart_options.title")}</h3>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Chart options</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("settings.general.chart_options.subtitle")}
|
||||
<Trans>Adjust display options for charts.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Label className="block" htmlFor="chartTime">
|
||||
{t("settings.general.chart_options.default_time_period")}
|
||||
<Trans>Default time period</Trans>
|
||||
</Label>
|
||||
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
|
||||
<SelectTrigger id="chartTime">
|
||||
@@ -98,13 +97,13 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{t("settings.general.chart_options.default_time_period_des")}
|
||||
<Trans>Sets the default time range for charts when a system is viewed.</Trans>
|
||||
</p>
|
||||
</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" />}
|
||||
{t("settings.save_settings")}
|
||||
<Trans>Save Settings</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,8 @@ import { UserSettings } from "@/types.js"
|
||||
import General from "./general.tsx"
|
||||
import Notifications from "./notifications.tsx"
|
||||
import ConfigYaml from "./config-yaml.tsx"
|
||||
import { isAdmin } from "@/lib/utils.ts"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
try {
|
||||
@@ -31,47 +30,45 @@ export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
})
|
||||
$userSettings.set(updatedSettings.settings)
|
||||
toast({
|
||||
title: t("settings.saved"),
|
||||
description: t("settings.saved_des"),
|
||||
title: t`Settings saved`,
|
||||
description: t`Your user settings have been updated.`,
|
||||
})
|
||||
} catch (e) {
|
||||
// console.error('update settings', e)
|
||||
toast({
|
||||
title: t("settings.failed_to_save"),
|
||||
description: t("settings.check_logs"),
|
||||
title: t`Failed to save settings`,
|
||||
description: t`Check logs for more details.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useTranslation()
|
||||
const { _ } = useLingui()
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: t("settings.general.title"),
|
||||
title: _(t({ message: `General`, comment: "Context: General settings" })),
|
||||
href: "/settings/general",
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
title: t("settings.notifications.title"),
|
||||
title: t`Notifications`,
|
||||
href: "/settings/notifications",
|
||||
icon: BellIcon,
|
||||
},
|
||||
]
|
||||
|
||||
if (isAdmin()) {
|
||||
sidebarNavItems.push({
|
||||
title: t("settings.yaml_config.short_title"),
|
||||
{
|
||||
title: t`YAML Config`,
|
||||
href: "/settings/config",
|
||||
icon: FileSlidersIcon,
|
||||
})
|
||||
}
|
||||
admin: true,
|
||||
},
|
||||
]
|
||||
|
||||
const page = useStore($router)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Settings / Beszel"
|
||||
document.title = t`Settings` + " / Beszel"
|
||||
// redirect to account page if no page is specified
|
||||
if (page?.path === "/settings") {
|
||||
redirectPage($router, "settings", { name: "general" })
|
||||
@@ -81,8 +78,12 @@ export default function SettingsLayout() {
|
||||
return (
|
||||
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="mb-1">{t("settings.settings")}</CardTitle>
|
||||
<CardDescription>{t("settings.subtitle")}</CardDescription>
|
||||
<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" />
|
||||
|
||||
@@ -12,8 +12,7 @@ import { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import * as v from "valibot"
|
||||
import { isAdmin } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { t } from "i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
@@ -27,8 +26,6 @@ const NotificationSchema = v.object({
|
||||
})
|
||||
|
||||
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
|
||||
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -62,7 +59,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
await saveSettings(parsedData)
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: t("settings.failed_to_save"),
|
||||
title: t`Failed to save settings`,
|
||||
description: e.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
@@ -73,51 +70,67 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xl font-medium mb-2">{t("settings.notifications.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t("settings.notifications.subtitle_1")}</p>
|
||||
<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">
|
||||
{t("settings.notifications.subtitle_2")} <BellIcon className="inline h-4 w-4" />{" "}
|
||||
{t("settings.notifications.subtitle_3")}
|
||||
<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="space-y-2">
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium">{t("settings.notifications.email.title")}</h3>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Email notifications</Trans>
|
||||
</h3>
|
||||
{isAdmin() && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("settings.notifications.email.please")}{" "}
|
||||
<a href="/_/#/settings/mail" className="link" target="_blank">
|
||||
{t("settings.notifications.email.configure_an_SMTP_server")}
|
||||
</a>{" "}
|
||||
{t("settings.notifications.email.to_ensure_alerts_are_delivered")}{" "}
|
||||
<Trans>
|
||||
Please{" "}
|
||||
<a href="/_/#/settings/mail" className="link" target="_blank">
|
||||
configure an SMTP server
|
||||
</a>{" "}
|
||||
to ensure alerts are delivered.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Label className="block" htmlFor="email">
|
||||
{t("settings.notifications.email.to_emails")}
|
||||
<Trans>To email(s)</Trans>
|
||||
</Label>
|
||||
<InputTags
|
||||
value={emails}
|
||||
onChange={setEmails}
|
||||
placeholder={t("settings.notifications.email.enter_email_address")}
|
||||
placeholder={t`Enter email address...`}
|
||||
className="w-full"
|
||||
type="email"
|
||||
id="email"
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">{t("settings.notifications.email.des")}</p>
|
||||
<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>
|
||||
<h3 className="mb-1 text-lg font-medium">{t("settings.notifications.webhook.title")}</h3>
|
||||
<h3 className="mb-1 text-lg font-medium">
|
||||
<Trans>Webhook / Push notifications</Trans>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("settings.notifications.webhook.des_1")}{" "}
|
||||
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
{t("settings.notifications.webhook.des_2")}
|
||||
<Trans>
|
||||
Beszel uses{" "}
|
||||
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
|
||||
Shoutrrr
|
||||
</a>{" "}
|
||||
to integrate with popular notification services.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{webhooks.length > 0 && (
|
||||
@@ -140,7 +153,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
||||
{t("settings.notifications.webhook.add")} URL
|
||||
<Trans>Add URL</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -151,7 +164,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||
{t("settings.save_settings")}
|
||||
<Trans>Save Settings</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,13 +179,13 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
||||
const res = await pb.send("/api/beszel/send-test-notification", { url })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t("settings.notifications.webhook.test_sent"),
|
||||
description: t("settings.notifications.webhook.test_sent_des"),
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: res.err ?? "Failed to send test notification",
|
||||
title: t`Error`,
|
||||
description: res.err ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
@@ -195,7 +208,9 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span>
|
||||
{t("settings.notifications.webhook.test")} <span className="hidden sm:inline">URL</span>
|
||||
<Trans>
|
||||
Test <span className="hidden sm:inline">URL</span>
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, isAdmin } from "@/lib/utils"
|
||||
import { buttonVariants } from "../../ui/button"
|
||||
import { $router, Link, navigate } from "../../router"
|
||||
import { useStore } from "@nanostores/react"
|
||||
@@ -11,6 +11,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
href: string
|
||||
title: string
|
||||
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||
admin?: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -23,17 +24,20 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
<div className="md:hidden">
|
||||
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
||||
<SelectTrigger className="w-full my-3.5">
|
||||
<SelectValue placeholder="Select a page" />
|
||||
<SelectValue placeholder="Select page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
if (item.admin && !isAdmin()) return null
|
||||
return (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from "@/lib/stores"
|
||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
|
||||
import { ChartData, ChartTimes, ContainerStatsRecord, SystemRecord, SystemStatsRecord } from "@/types"
|
||||
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||
@@ -15,7 +15,8 @@ import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||
import { timeTicks } from "d3-time"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Plural, Trans, t } from "@lingui/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
|
||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
||||
@@ -85,7 +86,8 @@ async function getStats<T>(collection: string, system: SystemRecord, chartTime:
|
||||
}
|
||||
|
||||
export default function SystemDetail({ name }: { name: string }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const direction = useStore($direction)
|
||||
const { _ } = useLingui()
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
/** Max CPU toggle value */
|
||||
@@ -157,10 +159,10 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
systemStats,
|
||||
containerData,
|
||||
chartTime,
|
||||
orientation: i18n.dir() == "rtl" ? "right" : "left",
|
||||
orientation: direction === "rtl" ? "right" : "left",
|
||||
...getTimeData(chartTime, lastCreated),
|
||||
}
|
||||
}, [systemStats, containerData, i18n.dir()])
|
||||
}, [systemStats, containerData, direction])
|
||||
|
||||
// get stats
|
||||
useEffect(() => {
|
||||
@@ -232,12 +234,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
if (!system.info) {
|
||||
return []
|
||||
}
|
||||
let uptime: number | string = system.info.u
|
||||
let uptime: React.ReactNode
|
||||
if (system.info.u < 172800) {
|
||||
const hours = Math.trunc(uptime / 3600)
|
||||
uptime = t("hours", { count: hours })
|
||||
const hours = Math.trunc(system.info.u / 3600)
|
||||
uptime = <Plural value={hours} one="# hour" other="# hours" />
|
||||
} else {
|
||||
uptime = t("days", { count: Math.trunc(system.info?.u / 86400) })
|
||||
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
||||
}
|
||||
return [
|
||||
{ value: system.host, Icon: GlobeIcon },
|
||||
@@ -248,8 +250,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
// hide if hostname is same as host or name
|
||||
hide: system.info.h === system.host || system.info.h === system.name,
|
||||
},
|
||||
{ value: uptime, Icon: ClockArrowUp, label: "Uptime" },
|
||||
{ value: system.info.k, Icon: TuxIcon, label: "Kernel" },
|
||||
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
|
||||
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
|
||||
{
|
||||
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
||||
Icon: CpuIcon,
|
||||
@@ -261,7 +263,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
Icon: any
|
||||
hide?: boolean
|
||||
}[]
|
||||
}, [system.info, t])
|
||||
}, [system.info])
|
||||
|
||||
/** Space for tooltip if more than 12 containers */
|
||||
useEffect(() => {
|
||||
@@ -345,7 +347,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("monitor.toggle_grid")}
|
||||
aria-label={t`Toggle grid`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden lg:flex p-0 text-primary"
|
||||
@@ -358,7 +360,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("monitor.toggle_grid")}</TooltipContent>
|
||||
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -370,26 +372,19 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.total_cpu_usage")}
|
||||
description={`${cpuMaxStore[0] && isLongerChart ? t("monitor.max_1_min") : t("monitor.average")} ${t(
|
||||
"monitor.cpu_des"
|
||||
)}`}
|
||||
title={_(t`CPU Usage`)}
|
||||
description={t`Average system-wide CPU utilization`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
chartName={t("alerts.info.cpu_usage")}
|
||||
maxToggled={cpuMaxStore[0]}
|
||||
unit="%"
|
||||
/>
|
||||
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
|
||||
</ChartCard>
|
||||
|
||||
{containerFilterBar && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.docker_cpu_usage")}
|
||||
description={t("monitor.docker_cpu_des")}
|
||||
title={t`Docker CPU Usage`}
|
||||
description={t`Average CPU utilization of containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
||||
@@ -399,8 +394,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.total_memory_usage")}
|
||||
description={t("monitor.memory_des")}
|
||||
title={t`Memory Usage`}
|
||||
description={t`Triggers when memory usage exceeds a threshold.`}
|
||||
>
|
||||
<MemChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
@@ -409,15 +404,15 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.docker_memory_usage")}
|
||||
description={t("monitor.docker_memory_des")}
|
||||
title={t`Docker Memory Usage`}
|
||||
description={t`Memory usage of docker containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t("monitor.disk_space")} description={t("monitor.disk_des")}>
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||
<DiskChart
|
||||
chartData={chartData}
|
||||
dataKey="stats.du"
|
||||
@@ -428,8 +423,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.disk_io")}
|
||||
description={t("monitor.disk_io_des")}
|
||||
title={t`Disk I/O`}
|
||||
description={t`Throughput of root filesystem`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
|
||||
@@ -438,9 +433,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.bandwidth")}
|
||||
title={t`Bandwidth`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
|
||||
description={t("monitor.bandwidth_des")}
|
||||
description={t`Network traffic of public interfaces`}
|
||||
>
|
||||
<AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
|
||||
</ChartCard>
|
||||
@@ -454,8 +449,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
title={t("monitor.docker_network_io")}
|
||||
description={t("monitor.docker_network_io_des")}
|
||||
title={t`Docker Network I/O`}
|
||||
description={t`Network traffic of docker containers`}
|
||||
cornerEl={containerFilterBar}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
@@ -468,8 +463,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.swap_usage")}
|
||||
description={t("monitor.swap_des")}
|
||||
title={t`Swap Usage`}
|
||||
description={t`Swap space used by the system`}
|
||||
>
|
||||
<SwapChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
@@ -479,8 +474,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t("monitor.temperature")}
|
||||
description={t("monitor.temperature_des")}
|
||||
title={t`Temperature`}
|
||||
description={t`Temperatures of system sensors`}
|
||||
>
|
||||
<TemperatureChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
@@ -496,8 +491,8 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${extraFsName} ${t("monitor.usage")}`}
|
||||
description={`${t("monitor.disk_usage_of")} ${extraFsName}`}
|
||||
title={`${extraFsName} ${t`Usage`}`}
|
||||
description={t`Disk usage of ${extraFsName}`}
|
||||
>
|
||||
<DiskChart
|
||||
chartData={chartData}
|
||||
@@ -509,7 +504,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${extraFsName} I/O`}
|
||||
description={`${t("monitor.throughput_of")} ${extraFsName}`}
|
||||
description={t`Throughput of ${extraFsName}`}
|
||||
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||
>
|
||||
<AreaChartDefault
|
||||
@@ -532,8 +527,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
}
|
||||
|
||||
function ContainerFilterBar() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const containerFilter = useStore($containerFilter)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -542,7 +535,7 @@ function ContainerFilterBar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input placeholder={t("filter")} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
|
||||
<Input placeholder={t`Filter...`} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
|
||||
{containerFilter && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -560,8 +553,6 @@ function ContainerFilterBar() {
|
||||
}
|
||||
|
||||
function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStateAction<boolean>>] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [max, setMax] = store
|
||||
const Icon = max ? ChartMax : ChartAverage
|
||||
|
||||
@@ -573,10 +564,10 @@ function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStat
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="avg" value="avg">
|
||||
{t("monitor.average")}
|
||||
<Trans>Average</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem key="max" value="max">
|
||||
{t("monitor.max_1_min")}
|
||||
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -599,7 +590,6 @@ function ChartCard({
|
||||
cornerEl?: JSX.Element | null
|
||||
}) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
|
||||
@@ -609,7 +599,7 @@ function ChartCard({
|
||||
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
|
||||
</CardHeader>
|
||||
<div className="ps-0 w-[calc(100%-1.6em)] h-52 relative">
|
||||
{<Spinner msg={empty ? t("monitor.waiting_for") : undefined} />}
|
||||
{<Spinner msg={empty ? t`Waiting for enough records to display` : undefined} />}
|
||||
{isIntersecting && children}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -56,7 +56,7 @@ import { cn, copyToClipboard, decimalString, isReadOnlyUser } from "@/lib/utils"
|
||||
import AlertsButton from "../alerts/alert-button"
|
||||
import { navigate } from "../router"
|
||||
import { EthernetIcon } from "../ui/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Trans, t } from "@lingui/macro"
|
||||
|
||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
const val = info.getValue() as number
|
||||
@@ -76,7 +76,7 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
)
|
||||
}
|
||||
|
||||
function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Icon: any, hideSortIcon = false) {
|
||||
function sortableHeader(column: Column<SystemRecord, unknown>, name: React.ReactNode, Icon: any, hideSortIcon = false) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -91,8 +91,6 @@ function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Ico
|
||||
}
|
||||
|
||||
export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const data = useStore($systems)
|
||||
const hubVersion = useStore($hubVersion)
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
@@ -136,32 +134,32 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.system"), ServerIcon),
|
||||
header: ({ column }) => sortableHeader(column, t`System`, ServerIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: "info.cpu",
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.cpu"), CpuIcon),
|
||||
header: ({ column }) => sortableHeader(column, t`CPU`, CpuIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: "info.mp",
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.memory"), MemoryStickIcon),
|
||||
header: ({ column }) => sortableHeader(column, t`Memory`, MemoryStickIcon),
|
||||
},
|
||||
{
|
||||
accessorKey: "info.dp",
|
||||
invertSorting: true,
|
||||
cell: CellFormatter,
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.disk"), HardDriveIcon),
|
||||
header: ({ column }) => sortableHeader(column, t`Disk`, HardDriveIcon),
|
||||
},
|
||||
{
|
||||
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||
id: "n",
|
||||
invertSorting: true,
|
||||
size: 115,
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.net"), EthernetIcon),
|
||||
header: ({ column }) => sortableHeader(column, t`Net`, EthernetIcon),
|
||||
cell: (info) => {
|
||||
const val = info.getValue() as number
|
||||
return (
|
||||
@@ -173,7 +171,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
accessorKey: "info.v",
|
||||
invertSorting: true,
|
||||
size: 50,
|
||||
header: ({ column }) => sortableHeader(column, t("systems_table.agent"), WifiIcon, true),
|
||||
header: ({ column }) => sortableHeader(column, t`Agent`, WifiIcon, true),
|
||||
cell: (info) => {
|
||||
const version = info.getValue() as string
|
||||
if (!version || !hubVersion) {
|
||||
@@ -203,7 +201,9 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={"icon"} data-nolink>
|
||||
<span className="sr-only">{t("systems_table.open_menu")}</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -219,43 +219,49 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
{status === "paused" ? (
|
||||
<>
|
||||
<PlayCircleIcon className="me-2.5 h-4 w-4" />
|
||||
{t("systems_table.resume")}
|
||||
<Trans>Resume</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PauseCircleIcon className="me-2.5 h-4 w-4" />
|
||||
{t("systems_table.pause")}
|
||||
<Trans>Pause</Trans>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||
<CopyIcon className="me-2.5 h-4 w-4" />
|
||||
{t("systems_table.copy_host")}
|
||||
<Trans>Copy host</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
|
||||
<Trash2Icon className="me-2.5 h-4 w-4" />
|
||||
{t("systems_table.delete")}
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("systems_table.delete_confirm", { name })}</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("systems_table.delete_confirm_des_1")} <code className="bg-muted rounded-sm px-1">{name}</code>{" "}
|
||||
{t("systems_table.delete_confirm_des_2")}
|
||||
<Trans>
|
||||
This action cannot be undone. This will permanently delete all current records for {name} from
|
||||
the database.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||
onClick={() => pb.collection("systems").delete(id)}
|
||||
>
|
||||
{t("continue")}
|
||||
<Trans>Continue</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -334,7 +340,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("systems_table.no_systems_found")}
|
||||
<Trans>No systems found.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user