diff --git a/CLAUDE.md b/CLAUDE.md index 3e601b453..498858e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ client/src/ - **Data Fetching**: SWR + Axios - **Database**: MongoDB with Mongoose ODM - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) -- **i18n**: i18next + react-i18next (translations via PoEditor) +- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service) --- diff --git a/client/src/Components/actions-menu/index.tsx b/client/src/Components/actions-menu/index.tsx index 3b2f3688a..2eca19cee 100644 --- a/client/src/Components/actions-menu/index.tsx +++ b/client/src/Components/actions-menu/index.tsx @@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import IconButton from "@mui/material/IconButton"; import { Settings } from "lucide-react"; +import { useTheme } from "@mui/material/styles"; export type ActionMenuItem = { id: number | string; @@ -12,6 +13,7 @@ export type ActionMenuItem = { }; export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { + const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { return (
- + ; onClick?: () => void; }>; @@ -21,6 +22,7 @@ export const BaseChart = ({ title, width = "100%", maxWidth = "100%", + flexBasis = "0%", padding, onClick, }: BaseChartProps) => { @@ -32,6 +34,7 @@ export const BaseChart = ({ padding: padding ?? theme.spacing(LAYOUT.MD), display: "flex", flex: 1, + flexBasis, width: width, maxWidth: maxWidth, ...(onClick && { diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index 4130f67a7..3c0d75dbe 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -39,9 +39,9 @@ export const Gauge = forwardRef(function Gauge( () => ({ circumference: 2 * Math.PI * radius, totalSize: radius * 2 + strokeWidth * 2, - strokeLength: (progress / 100) * (2 * Math.PI * radius), + strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius), }), - [radius, strokeWidth, progress] + [radius, strokeWidth, progressWithinRange] ); const [offset, setOffset] = useState(circumference); @@ -52,7 +52,7 @@ export const Gauge = forwardRef(function Gauge( }, 100); return () => clearTimeout(timer); - }, [progress, circumference, strokeLength]); + }, [circumference, strokeLength]); const fillColor = getInfraGaugeColor(progressWithinRange, theme); @@ -123,6 +123,7 @@ export const DetailGauge = ({ lowerLabel, lowerValue, maxWidth = 225, + flexBasis = "0%", }: { title: string; progress: number; @@ -131,6 +132,7 @@ export const DetailGauge = ({ lowerLabel?: string; lowerValue?: string | number; maxWidth?: number; + flexBasis?: number | string; }) => { const theme = useTheme(); return ( @@ -138,6 +140,7 @@ export const DetailGauge = ({ icon={null} title={title} maxWidth={maxWidth} + flexBasis={flexBasis} > ; -export const GradientBox = ({ children, palette, sx }: GradientBox) => { +export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isLight = theme.palette.mode === "light"; @@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => { padding: `${theme.spacing(4)} ${theme.spacing(8)}`, width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`, background: bg, + ...(interactive && { + cursor: "pointer", + "&:hover": { + backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`, + }, + }), ...sx, }} > @@ -62,10 +69,8 @@ export const StatBox = ({ return ( diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index a02f9be8f..df0d52b16 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -79,6 +79,7 @@ export function DataTable< }; const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const isInteractive = Boolean(onRowClick) || expandableRows; if (data.length === 0 || headers.length === 0) { return ( @@ -108,6 +109,11 @@ export function DataTable< borderRadius: theme.shape.borderRadius, padding: theme.spacing(LAYOUT.XS), cursor: onRowClick ? "pointer" : "default", + ...(onRowClick && { + "&:hover": { + backgroundColor: theme.palette.action.rowHover, + }, + }), }} key={key} > @@ -207,6 +213,10 @@ export function DataTable< "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": { borderBottom: "none", }, + "& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root": + { + backgroundColor: theme.palette.action.rowHover, + }, }} > @@ -231,8 +241,9 @@ export function DataTable< return ( { @@ -292,6 +303,12 @@ interface TablePaginationActionsProps { onPageChange: (event: React.MouseEvent, newPage: number) => void; } +const controlHoverSx = (theme: Theme): SxProps => ({ + "&:hover": { + backgroundColor: theme.palette.action.controlHover, + }, +}); + function TablePaginationActions(props: TablePaginationActionsProps) { const theme = useTheme(); const { count, page, rowsPerPage, onPageChange } = props; @@ -321,6 +338,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) { onClick={handleFirstPageButtonClick} disabled={page === 0} aria-label="first page" + sx={controlHoverSx(theme)} > {theme.direction === "rtl" ? ( {theme.direction === "rtl" ? ( = Math.ceil(count / rowsPerPage) - 1} aria-label="next page" + sx={controlHoverSx(theme)} > {theme.direction === "rtl" ? ( = Math.ceil(count / rowsPerPage) - 1} aria-label="last page" + sx={controlHoverSx(theme)} > {theme.direction === "rtl" ? ( {theme.direction === "rtl" ? ( {theme.direction === "rtl" ? ( {theme.direction === "rtl" ? ( { return [ { - name: t("components.sidebar.menu.uptime"), - path: "uptime", - icon: , + group: t("components.sidebar.menu.groups.monitoring"), + items: [ + { + name: t("components.sidebar.menu.uptime"), + path: "uptime", + icon: , + }, + { + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , + }, + { + name: t("components.sidebar.menu.infrastructure"), + path: "infrastructure", + icon: , + }, + { + name: t("components.sidebar.menu.docker"), + path: "docker", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.pagespeed"), - path: "pagespeed", - icon: , + group: t("components.sidebar.menu.groups.alerting"), + items: [ + { + name: t("components.sidebar.menu.incidents"), + path: "incidents", + icon: , + }, + { + name: t("components.sidebar.menu.notifications"), + path: "notifications", + icon: , + }, + { + name: t("components.sidebar.menu.maintenance"), + path: "maintenance", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.infrastructure"), - path: "infrastructure", - icon: , + group: t("components.sidebar.menu.groups.reporting"), + items: [ + { + name: t("components.sidebar.menu.statusPages"), + path: "status", + icon: , + }, + { + name: t("components.sidebar.menu.checks"), + path: "checks", + icon: , + }, + { + name: t("components.sidebar.menu.logs"), + path: "logs", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.docker"), - path: "docker", - icon: , - }, - { - name: t("components.sidebar.menu.notifications"), - path: "notifications", - icon: , - }, - { - name: t("components.sidebar.menu.tags"), - path: "tags", - icon: , - }, - { - name: t("components.sidebar.menu.proxies"), - path: "proxies", - icon: , - }, - { - name: t("components.sidebar.menu.checks"), - path: "checks", - icon: , - }, - { - name: t("components.sidebar.menu.incidents"), - path: "incidents", - icon: , - }, - { - name: t("components.sidebar.menu.statusPages"), - path: "status", - icon: , - }, - { - name: t("components.sidebar.menu.maintenance"), - path: "maintenance", - icon: , - }, - { - name: t("components.sidebar.menu.logs"), - path: "logs", - icon: , - }, - { - name: t("components.sidebar.menu.settings"), - icon: , - path: "settings", + group: t("components.sidebar.menu.groups.configuration"), + items: [ + { + name: t("components.sidebar.menu.tags"), + path: "tags", + icon: , + }, + { + name: t("components.sidebar.menu.proxies"), + path: "proxies", + icon: , + }, + { + name: t("components.sidebar.menu.settings"), + path: "settings", + icon: , + }, + ], }, ]; }; diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx index d92441b01..1d49f8f42 100644 --- a/client/src/Components/sidebar/index.tsx +++ b/client/src/Components/sidebar/index.tsx @@ -1,9 +1,12 @@ import { useEffect } from "react"; import Backdrop from "@mui/material/Backdrop"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; import List from "@mui/material/List"; import Divider from "@mui/material/Divider"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { typographyLevels } from "@/Utils/Theme/Palette"; import { useSidebar } from "@/Hooks/useSidebar.js"; import { Logo } from "@/Components/sidebar/Logo"; import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu"; @@ -29,7 +32,7 @@ export const Sidebar = () => { const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation(); - const { width, transition, collapsed } = useSidebar(); + const { width, transition, transitionTiming, collapsed } = useSidebar(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const menu = getMenu(t); @@ -89,17 +92,52 @@ export const Sidebar = () => { pt={theme.spacing(LAYOUT.MD)} pb={theme.spacing(LAYOUT.MD)} /> - {menu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - handleNavClick(item.path)} - /> - ); - })} + {menu.map((group) => ( + + + + {group.group} + + + + {group.items.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + ))} diff --git a/client/src/Hooks/useSidebar.ts b/client/src/Hooks/useSidebar.ts index 1cf705dd1..0229d8640 100644 --- a/client/src/Hooks/useSidebar.ts +++ b/client/src/Hooks/useSidebar.ts @@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250; const SIDEBAR_COLLAPSED_WIDTH_VAR = 64; // Transition timing for sidebar width changes -const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; +const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; +const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`; export const useSidebar = () => { const collapsed = useSelector( @@ -19,6 +20,7 @@ export const useSidebar = () => { expandedWidth: SIDEBAR_WIDTH_VAR, width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, transition: SIDEBAR_TRANSITION, + transitionTiming: SIDEBAR_TRANSITION_TIMING, }; }; diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx index c84a327fa..b302b939d 100644 --- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx @@ -63,19 +63,42 @@ const getChartConfigs = ( return configs; }; -// Loopback / bridge names Capture sees when it can only observe the container's -// own network namespace. When ALL reported interfaces match this set we assume -// the "no real interfaces visible" case and surface the notice. +// Loopback / bridge names Capture reports when it can only observe the +// container's own network namespace. const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]); +// Below this, an interface is carrying no traffic worth charting. Real host +// NICs sit orders of magnitude above it; a container's veth sits at or near +// zero. +const IDLE_INTERFACE_BYTES_PER_SECOND = 1024; + +const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean => + checks.every((check) => { + const iface = check.net?.find((candidate) => candidate.name === name); + if (!iface) { + return true; + } + return ( + (iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND && + (iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND + ); + }); + const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => { const named = new Set(); checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name))); - if (named.size === 0) return false; - for (const name of named) { - if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false; + if (named.size === 0) { + return false; } - return true; + + const names = [...named]; + if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) { + return true; + } + if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) { + return false; + } + return names.every((name) => interfaceIsIdle(checks, name)); }; export const InfraNetworkCharts = ({ diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx index e2b053aef..eba54070d 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx @@ -8,6 +8,8 @@ import { useTheme } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { CheckSnapshot } from "@/Types/Check"; +const GAUGE_MAX_WIDTH = 260; + export const InfraDetailsGauges = ({ snapshot, }: { @@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({ direction={isSmall ? "column" : "row"} spacing={theme.spacing(8)} alignItems={"stretch"} + flexWrap={"wrap"} + useFlexGap > {snapshot?.disk?.map((disk, idx) => { return ( ); })} diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 0372fe749..6445e6e89 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,7 +1,8 @@ import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { logger } from "@/Utils/logger"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; -import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements"; import { Button } from "@/Components/inputs"; import { useTheme } from "@mui/material"; @@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; import { useForm, FormProvider } from "react-hook-form"; import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; @@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => { ); const { data: monitors } = useGet("/monitors/team"); + const { data: tags } = useGet("/tags/team"); const { post, loading: isPosting } = usePost(); const { patch, loading: isPatching } = usePatch(); @@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => { "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" )} options={monitors ?? []} + renderOptionContent={(monitor) => ( + + {monitor.name} + {monitor.tags.map((tagId) => { + const tag = tags?.find(({ id }) => id === tagId); + return tag ? ( + + ) : null; + })} + + )} /> } /> diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 70a8e1bb6..c8fccfd3f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; +import { BasePage, ConfigBox, TextLink, Tabs, Tab } from "@/Components/design-elements"; import { Autocomplete, Select, Dialog } from "@/Components/inputs"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; @@ -46,6 +46,80 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import type { ProxyResponse } from "@/Types/Proxy"; import type { AppSettingsResponse } from "@/Types/Settings"; +import { useSearchParams } from "react-router-dom"; + +const SETTINGS_TABS = [ + { key: "general", labelKey: "pages.settings.tabs.general" }, + { key: "monitoring", labelKey: "pages.settings.tabs.monitoring", adminOnly: true }, + { key: "email", labelKey: "pages.settings.tabs.email", adminOnly: true }, + { key: "integrations", labelKey: "pages.settings.tabs.integrations", adminOnly: true }, + { key: "data", labelKey: "pages.settings.tabs.data", adminOnly: true }, +] as const; + +type SettingsTabKey = (typeof SETTINGS_TABS)[number]["key"]; + +const FIELD_TAB: Record = { + checkTTL: "monitoring", + globalThresholds: "monitoring", + pagespeedApiKey: "integrations", + globalProxyEnabled: "integrations", + globalProxyId: "integrations", + showURL: "general", +}; + +const tabForField = (field: string): SettingsTabKey => + field.startsWith("systemEmail") + ? "email" + : (FIELD_TAB[field.split(".")[0]] ?? "general"); + +const FIELD_LABEL_KEY: Record = { + checkTTL: "pages.settings.form.retention.option.days.label", + "globalThresholds.cpu": "pages.settings.form.thresholds.option.cpu.label", + "globalThresholds.memory": "pages.settings.form.thresholds.option.memory.label", + "globalThresholds.disk": "pages.settings.form.thresholds.option.disk.label", + "globalThresholds.temperature": + "pages.settings.form.thresholds.option.temperature.label", + pagespeedApiKey: "pages.settings.form.pagespeed.option.apiKey.label", + globalProxyEnabled: "pages.settings.form.globalProxy.option.enabled.label", + showURL: "pages.settings.form.url.option.showURL.label", + systemEmailHost: "pages.settings.form.email.option.host.label", + systemEmailPort: "pages.settings.form.email.option.port.label", + systemEmailAddress: "pages.settings.form.email.option.address.label", + systemEmailDisplayName: "pages.settings.form.email.option.displayName.label", + systemEmailUser: "pages.settings.form.email.option.user.label", + systemEmailPassword: "pages.settings.form.email.option.password.label", + systemEmailTLSServername: "pages.settings.form.email.option.tlsServername.label", + systemEmailConnectionHost: "pages.settings.form.email.option.connectionHost.label", + systemEmailSecure: "pages.settings.form.email.option.secure.label", + systemEmailPool: "pages.settings.form.email.option.pool.label", + systemEmailIgnoreTLS: "pages.settings.form.email.option.ignoreTLS.label", + systemEmailRequireTLS: "pages.settings.form.email.option.requireTLS.label", + systemEmailRejectUnauthorized: + "pages.settings.form.email.option.rejectUnauthorized.label", +}; + +type FieldErrorLeaf = { path: string; message?: string }; + +const flattenFieldErrors = (errors: unknown, prefix = ""): FieldErrorLeaf[] => { + if (typeof errors !== "object" || errors === null) { + return []; + } + return Object.entries(errors).flatMap(([key, value]) => { + if (key === "ref") { + return []; + } + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value !== "object" || value === null) { + return []; + } + const message = (value as { message?: unknown }).message; + if (typeof message === "string") { + return [{ path, message }]; + } + const nested = flattenFieldErrors(value, path); + return nested.length > 0 ? nested : [{ path }]; + }); +}; export const SettingsPage = () => { const theme = useTheme(); @@ -53,6 +127,42 @@ export const SettingsPage = () => { const dispatch = useDispatch(); const isAdmin = useIsAdmin(); const { toastError } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); + + const visibleTabs = useMemo( + () => SETTINGS_TABS.filter((tab) => !("adminOnly" in tab) || isAdmin), + [isAdmin] + ); + + const requestedTab = searchParams.get("tab"); + const firstVisibleTab: SettingsTabKey = visibleTabs[0]?.key ?? SETTINGS_TABS[0].key; + const activeTab: SettingsTabKey = + visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? firstVisibleTab; + + const handleTabChange = (tab: SettingsTabKey) => { + const updated = new URLSearchParams(searchParams); + if (tab === firstVisibleTab) { + updated.delete("tab"); + } else { + updated.set("tab", tab); + } + setSearchParams(updated, { replace: true }); + }; + + // Drop a "tab" param that does not resolve to a rendered tab, or that names + // the first tab, which is addressed with no param at all. + useEffect(() => { + if (requestedTab === null) { + return; + } + if (requestedTab === activeTab && activeTab !== firstVisibleTab) { + return; + } + const updated = new URLSearchParams(searchParams); + updated.delete("tab"); + setSearchParams(updated, { replace: true }); + }, [requestedTab, activeTab, firstVisibleTab, searchParams, setSearchParams]); + // Local state for demo monitors dialog const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); @@ -300,6 +410,13 @@ export const SettingsPage = () => { logger.debug("Form validation errors", errors); }; + const revealField = (field: string) => { + const target = tabForField(field); + if (visibleTabs.some((tab) => tab.key === target)) { + handleTabChange(target); + } + }; + const languages = Object.keys(i18n.options.resources || {}); const globalProxyEnabled = watch("globalProxyEnabled"); @@ -324,545 +441,594 @@ export const SettingsPage = () => { component="form" onSubmit={form.handleSubmit(onSubmit, onError)} > - - option.name} - isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) => - option.id === value.id - } - onChange={(_, newValue: TimezoneOption | null) => { - handleTimezoneChange(newValue); - }} - fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} + {visibleTabs.length > 1 && ( + handleTabChange(value)} + > + {visibleTabs.map((tab) => ( + - } - /> - - - - - - - } - /> - {isAdmin && ( - 0 ? ( - - - {globalProxyEnabled && ( - - )} - - ) : ( - - ) - } - /> - )} - {isAdmin && ( - - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - - )} + ))} + + )} - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} - - - - )} - - } - /> - )} - - {/* URL Settings */} - - } - /> - - {/* Clear All Stats */} - {isAdmin && ( + {/* general tab */} + {activeTab === "general" && ( + setIsStatsDialogOpen(true)} - > - {t("common.buttons.clear")} - - } - /> - )} - - {/* Check Retention */} - {isAdmin && ( - - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - formatDisplayValue={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` + option.name} + isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) => + option.id === value.id } + onChange={(_, newValue: TimezoneOption | null) => { + handleTimezoneChange(newValue); + }} + fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> } /> - )} - - {/* Global Thresholds */} - {isAdmin && ( - - - - - + + + + + } /> - )} - - - {/* Email Settings - Admin Only */} - {isAdmin && ( - - - {(() => { - const address = form.watch("systemEmailAddress") || ""; - const displayName = form.watch("systemEmailDisplayName")?.trim(); - return ( - <> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", - }, - name: - form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - - {address && ( - - - {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} - - + } + /> + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + )} + + {/* monitoring tab */} + {activeTab === "monitoring" && ( + + {/* Global Thresholds */} + {isAdmin && ( + + + + + + - ); - })()} - - } - rightContent={ - - {/* Email Host */} - + valueLabelDisplay="auto" + min={1} + max={150} + /> + + } + /> + )} + {/* Check Retention */} + {isAdmin && ( + + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + } + /> + )} - {/* Email Port */} - - - {/* Email Address */} - - - {/* Email Display Name (Optional) */} - - - {/* Email User (Optional) */} - - - {/* Email Password with Reset Pattern */} - {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + - - {t("pages.settings.form.email.option.password.labelSet")} - - { + await postDemoMonitors("/monitors/demo", {}); + }} + > + {t("common.buttons.addDemo")} + + + } + /> + )} + + )} + + {/* email tab */} + {activeTab === "email" && ( + + {/* Email Settings - Admin Only */} + {isAdmin && ( + + + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: + form.watch("systemEmailConnectionHost") || + "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: + form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: + form.watch("systemEmailRequireTLS") ?? false, + servername: + form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} + + + {address && ( + + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + + )} + + ); + })()} + + } + rightContent={ + + {/* Email Host */} + + + {/* Email Port */} + + + {/* Email Address */} + + + {/* Email Display Name (Optional) */} + + + {/* Email User (Optional) */} + + + {/* Email Password with Reset Pattern */} + {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( + + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + + )} + + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + } + /> + )} + + )} + + {/* integrations tab */} + {activeTab === "integrations" && ( + + {isAdmin && ( + 0 ? ( + + + {globalProxyEnabled && ( + + )} - - ) : ( - - )} - - {/* TLS Servername (Optional) */} - - - {/* Connection Host (Optional) */} - - - {/* Boolean Switches */} - - {emailSwitches.map(({ name, label }) => ( - - ))} - + ) + } + /> + )} + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + + )} - {/* Test Email Button */} - + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + + + + )} + + } + /> + )} + + )} + + {/* data tab */} + {activeTab === "data" && ( + + {/* Export Monitors - Admin Only */} + {isAdmin && ( + + + + + + } + /> + )} + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} > - {t("common.buttons.sendTestEmail")} + {t("common.buttons.clear")} - - - } - /> - )} + } + /> + )} - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> + {/* Remove All Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + )} - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* About */} - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.form.about.developedBy")} - - - https://github.com/bluewave-labs/checkmate - - - } - /> - {/* Clear Stats Confirmation Dialog */} { { > {/* Validation Error Display */} {Object.keys(form.formState.errors).length > 0 && ( - + { component="ul" sx={{ m: 0, pl: 2 }} > - {Object.entries(form.formState.errors).map(([field, error]) => { - const message = - typeof error === "object" && error?.message - ? error.message - : "Invalid value"; + {flattenFieldErrors(form.formState.errors).map(({ path, message }) => { + const labelKey = FIELD_LABEL_KEY[path]; + // Labels read "Name - explanation"; keep the name. + const label = labelKey ? t(labelKey).split(" - ")[0] : path; return ( -
  • +
  • - {field}: {message} + revealField(path)} + > + {label} + + {message ? `: ${message}` : ""}
  • ); diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 5257ab92b..0d9168f8a 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -1,4 +1,5 @@ import { alpha } from "@mui/material/styles"; +import { HOVER } from "@/Utils/Theme/constants"; const typographyBase = 13; @@ -39,6 +40,10 @@ export const lightPalette = { primary: { main: colors.brandGreen, }, + action: { + rowHover: alpha(colors.gray900, HOVER.ROW), + controlHover: alpha(colors.gray900, HOVER.CONTROL), + }, secondary: { main: colors.gray200, }, @@ -65,6 +70,10 @@ export const darkPalette = { primary: { main: colors.brandGreen, }, + action: { + rowHover: alpha("#FFFFFF", HOVER.ROW), + controlHover: alpha("#FFFFFF", HOVER.CONTROL), + }, secondary: { main: colors.gray700, }, diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index b589be3a3..c63929a60 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -13,6 +13,10 @@ declare module "@mui/material/styles" { eyebrow?: React.CSSProperties; fontFamilyMonospace?: string; } + interface TypeAction { + rowHover: string; + controlHover: string; + } interface Palette { sidebar: { accent: string }; rowStatus: { running: string; paused: string }; diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts index 518657183..70ddcd407 100644 --- a/client/src/Utils/Theme/constants.ts +++ b/client/src/Utils/Theme/constants.ts @@ -20,4 +20,6 @@ export const LAYOUT = { export const HOVER = { DARKEN: 0.06, // This is a coefficient for darkening function + ROW: 0.025, // Overlay alpha for hoverable rows and cards + CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row } as const; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 55f34650d..82015e849 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -181,7 +181,13 @@ "statusPages": "صفحات الحالة", "maintenance": "الصيانة", "logs": "السجلات", - "settings": "الإعدادات" + "settings": "الإعدادات", + "groups": { + "monitoring": "المراقبة", + "alerting": "التنبيهات", + "reporting": "التقارير", + "configuration": "الإعدادات العامة" + } }, "bottomMenu": { "support": "الدعم", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "عام", + "monitoring": "المراقبة", + "email": "البريد الإلكتروني", + "integrations": "التكاملات", + "data": "البيانات" + }, "form": { "timezone": { "title": "المنطقة الزمنية للعرض", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 7e39eca86..912c5b198 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -189,7 +189,13 @@ "statusPages": "Pàgines d'estat", "maintenance": "Manteniment", "logs": "Registres", - "settings": "Configuració" + "settings": "Configuració", + "groups": { + "monitoring": "Monitoratge", + "alerting": "Alertes", + "reporting": "Informes", + "configuration": "Configuració" + } }, "bottomMenu": { "support": "Suport", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "General", + "monitoring": "Monitoratge", + "email": "Correu electrònic", + "integrations": "Integracions", + "data": "Dades" + }, "form": { "timezone": { "title": "Zona horària de visualització", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index d04b9b23b..1d98d565a 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -181,7 +181,13 @@ "statusPages": "Stavové stránky", "maintenance": "Údržba", "logs": "Záznamy", - "settings": "Nastavení" + "settings": "Nastavení", + "groups": { + "monitoring": "Monitorování", + "alerting": "Upozornění", + "reporting": "Přehledy", + "configuration": "Konfigurace" + } }, "bottomMenu": { "support": "Podpora", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Obecné", + "monitoring": "Monitorování", + "email": "E-mail", + "integrations": "Integrace", + "data": "Data" + }, "form": { "timezone": { "title": "Zobrazované časové pásmo", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index d43d5d52a..6a39ee1aa 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -181,7 +181,13 @@ "statusPages": "Statusseite", "maintenance": "Wartung", "logs": "Protokolle", - "settings": "Einstellungen" + "settings": "Einstellungen", + "groups": { + "monitoring": "Überwachung", + "alerting": "Benachrichtigungen", + "reporting": "Berichte", + "configuration": "Konfiguration" + } }, "bottomMenu": { "support": "Support", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Allgemein", + "monitoring": "Überwachung", + "email": "E-Mail", + "integrations": "Integrationen", + "data": "Daten" + }, "form": { "timezone": { "title": "Anzeigesprache Zeitzone", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 735da51e2..777a89c7a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -212,7 +212,13 @@ "statusPages": "Status pages", "maintenance": "Maintenance", "logs": "Logs", - "settings": "Settings" + "settings": "Settings", + "groups": { + "monitoring": "Monitoring", + "alerting": "Alerting", + "reporting": "Reporting", + "configuration": "Configuration" + } }, "bottomMenu": { "support": "Support", @@ -945,7 +951,7 @@ "memory": "Memory usage", "netBytesRecv": "{{name}} - Bytes Received", "netBytesSent": "{{name}} - Bytes Sent", - "loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.", + "loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.", "temp": "CPU temperature" } }, @@ -1352,6 +1358,13 @@ } }, "settings": { + "tabs": { + "general": "General", + "monitoring": "Monitoring", + "email": "Email", + "integrations": "Integrations", + "data": "Data" + }, "form": { "timezone": { "title": "Display timezone", @@ -1409,7 +1422,7 @@ } }, "url": { - "title": "Monitor IP/URL on Status Page", + "title": "Monitor IP/URL on status page", "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "option": { "showURL": { @@ -1441,7 +1454,7 @@ } }, "thresholds": { - "title": "Global Thresholds", + "title": "Global thresholds", "description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.", "option": { "cpu": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 589399a83..ac7e47c83 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -181,7 +181,13 @@ "statusPages": "Páginas de estado", "maintenance": "Mantenimiento", "logs": "Registros", - "settings": "Configuración" + "settings": "Configuración", + "groups": { + "monitoring": "Monitorización", + "alerting": "Alertas", + "reporting": "Informes", + "configuration": "Configuración" + } }, "bottomMenu": { "support": "Soporte", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "General", + "monitoring": "Monitorización", + "email": "Correo electrónico", + "integrations": "Integraciones", + "data": "Datos" + }, "form": { "timezone": { "title": "Zona horaria de visualización", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 030698d44..53bb68fbc 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -181,7 +181,13 @@ "statusPages": "Tilasivut", "maintenance": "Huolto", "logs": "Lokit", - "settings": "Asetukset" + "settings": "Asetukset", + "groups": { + "monitoring": "Valvonta", + "alerting": "Hälytykset", + "reporting": "Raportointi", + "configuration": "Määritykset" + } }, "bottomMenu": { "support": "Tuki", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Yleiset", + "monitoring": "Valvonta", + "email": "Sähköposti", + "integrations": "Integraatiot", + "data": "Tiedot" + }, "form": { "timezone": { "title": "Näytettävä aikavyöhyke", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 1e0a6b096..da3f965be 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -181,7 +181,13 @@ "statusPages": "Pages de statut", "maintenance": "Maintenance", "logs": "Journaux", - "settings": "Paramètres" + "settings": "Paramètres", + "groups": { + "monitoring": "Surveillance", + "alerting": "Alertes", + "reporting": "Rapports", + "configuration": "Configuration" + } }, "bottomMenu": { "support": "Support", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Général", + "monitoring": "Surveillance", + "email": "E-mail", + "integrations": "Intégrations", + "data": "Données" + }, "form": { "timezone": { "title": "Fuseau horaire d'affichage", diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 3d6d1ea16..4f4714675 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -193,7 +193,13 @@ "statusPages": "Pagine di stato", "maintenance": "Manutenzione", "logs": "Log", - "settings": "Impostazioni" + "settings": "Impostazioni", + "groups": { + "monitoring": "Monitoraggio", + "alerting": "Avvisi", + "reporting": "Report", + "configuration": "Configurazione" + } }, "bottomMenu": { "support": "Supporto", @@ -1237,6 +1243,13 @@ } }, "settings": { + "tabs": { + "general": "Generale", + "monitoring": "Monitoraggio", + "email": "Email", + "integrations": "Integrazioni", + "data": "Dati" + }, "form": { "timezone": { "title": "Fuso orario di visualizzazione", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 16e2ec641..cf05c64b0 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -181,7 +181,13 @@ "statusPages": "ステータスページ", "maintenance": "メンテナンス", "logs": "ログ", - "settings": "設定" + "settings": "設定", + "groups": { + "monitoring": "監視", + "alerting": "アラート", + "reporting": "レポート", + "configuration": "設定" + } }, "bottomMenu": { "support": "サポート", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "一般", + "monitoring": "監視", + "email": "メール", + "integrations": "連携", + "data": "データ" + }, "form": { "timezone": { "title": "表示タイムゾーン", diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 81509ee6f..5afb59748 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -214,7 +214,13 @@ "settings": "Ustawienia", "statusPages": "Strony statusu", "tags": "Tagi", - "uptime": "Czas działania" + "uptime": "Czas działania", + "groups": { + "monitoring": "Monitorowanie", + "alerting": "Alerty", + "reporting": "Raporty", + "configuration": "Konfiguracja" + } }, "starPrompt": { "description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub", @@ -1207,6 +1213,13 @@ } }, "settings": { + "tabs": { + "general": "Ogólne", + "monitoring": "Monitorowanie", + "email": "E-mail", + "integrations": "Integracje", + "data": "Dane" + }, "form": { "about": { "developedBy": "Opracowane przez Bluewave Labs", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index c75ead3ef..225c212a2 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -181,7 +181,13 @@ "statusPages": "Pagina de status", "maintenance": "Manutenção", "logs": "Registros", - "settings": "Configurações" + "settings": "Configurações", + "groups": { + "monitoring": "Monitoramento", + "alerting": "Alertas", + "reporting": "Relatórios", + "configuration": "Configuração" + } }, "bottomMenu": { "support": "Suporte", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Geral", + "monitoring": "Monitoramento", + "email": "E-mail", + "integrations": "Integrações", + "data": "Dados" + }, "form": { "timezone": { "title": "Fuso horário de exibição", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index cf5e486f7..ff9960ebe 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -181,7 +181,13 @@ "statusPages": "Страницы статуса", "maintenance": "Обслуживание", "logs": "Логи", - "settings": "Настройки" + "settings": "Настройки", + "groups": { + "monitoring": "Мониторинг", + "alerting": "Оповещения", + "reporting": "Отчёты", + "configuration": "Конфигурация" + } }, "bottomMenu": { "support": "Поддержка", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Общие", + "monitoring": "Мониторинг", + "email": "Электронная почта", + "integrations": "Интеграции", + "data": "Данные" + }, "form": { "timezone": { "title": "Часовой пояс отображения", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 66d354adc..e6a0d8389 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -181,7 +181,13 @@ "statusPages": "หน้าสถานะ", "maintenance": "การบำรุงรักษา", "logs": "บันทึก", - "settings": "การตั้งค่า" + "settings": "การตั้งค่า", + "groups": { + "monitoring": "การตรวจสอบ", + "alerting": "การแจ้งเตือน", + "reporting": "รายงาน", + "configuration": "การกำหนดค่า" + } }, "bottomMenu": { "support": "การสนับสนุน", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "ทั่วไป", + "monitoring": "การตรวจสอบ", + "email": "อีเมล", + "integrations": "การเชื่อมต่อ", + "data": "ข้อมูล" + }, "form": { "timezone": { "title": "เขตเวลาที่แสดง", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 26f8aace0..dc0f3444c 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -181,7 +181,13 @@ "statusPages": "Durum sayfaları", "maintenance": "Bakım", "logs": "Günlükler", - "settings": "Ayarlar" + "settings": "Ayarlar", + "groups": { + "monitoring": "İzleme", + "alerting": "Uyarılar", + "reporting": "Raporlama", + "configuration": "Yapılandırma" + } }, "bottomMenu": { "support": "Destek", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Genel", + "monitoring": "İzleme", + "email": "E-posta", + "integrations": "Entegrasyonlar", + "data": "Veri" + }, "form": { "timezone": { "title": "Görüntüleme saat dilimi", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index d1cb983d1..807a9b2d0 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -181,7 +181,13 @@ "statusPages": "Сторінки статусу", "maintenance": "Обслуговування", "logs": "Журнали", - "settings": "Налаштування" + "settings": "Налаштування", + "groups": { + "monitoring": "Моніторинг", + "alerting": "Сповіщення", + "reporting": "Звіти", + "configuration": "Конфігурація" + } }, "bottomMenu": { "support": "Підтримка", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Загальні", + "monitoring": "Моніторинг", + "email": "Електронна пошта", + "integrations": "Інтеграції", + "data": "Дані" + }, "form": { "timezone": { "title": "Часовий пояс відображення", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 81da1c6fc..899c8cb0c 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -181,7 +181,13 @@ "statusPages": "Trang trạng thái", "maintenance": "Bảo trì", "logs": "Nhật ký", - "settings": "Cài đặt" + "settings": "Cài đặt", + "groups": { + "monitoring": "Giám sát", + "alerting": "Cảnh báo", + "reporting": "Báo cáo", + "configuration": "Cấu hình" + } }, "bottomMenu": { "support": "Hỗ trợ", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "Chung", + "monitoring": "Giám sát", + "email": "Email", + "integrations": "Tích hợp", + "data": "Dữ liệu" + }, "form": { "timezone": { "title": "Múi giờ hiển thị", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 1a455d41c..959d09f38 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -181,7 +181,13 @@ "statusPages": "状态页", "maintenance": "维护", "logs": "日志", - "settings": "设置" + "settings": "设置", + "groups": { + "monitoring": "监控", + "alerting": "告警", + "reporting": "报表", + "configuration": "配置" + } }, "bottomMenu": { "support": "支持", @@ -1142,6 +1148,13 @@ } }, "settings": { + "tabs": { + "general": "常规", + "monitoring": "监控", + "email": "邮件", + "integrations": "集成", + "data": "数据" + }, "form": { "timezone": { "title": "显示时区", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index a15a2a2bb..16849cc6e 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -181,7 +181,13 @@ "statusPages": "狀態頁面", "maintenance": "維護", "logs": "日誌", - "settings": "設定" + "settings": "設定", + "groups": { + "monitoring": "監控", + "alerting": "警示", + "reporting": "報表", + "configuration": "設定" + } }, "bottomMenu": { "support": "支援", @@ -1141,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "一般", + "monitoring": "監控", + "email": "郵件", + "integrations": "整合", + "data": "資料" + }, "form": { "timezone": { "title": "顯示時區",