From 9d0f0116f3222874447a159f209924ec879870fc Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 21:31:09 -0400 Subject: [PATCH 1/5] feat(client): group settings into five tabs The settings page rendered all 13 ConfigBox sections in one column, so finding anything meant scrolling past everything else. The sections are now grouped under five tabs by subject: General timezone, appearance, status page URL, about Monitoring thresholds, check retention, demo monitors Email email settings Integrations global proxy, PageSpeed API key Data import/export, monitor history, system reset No section changed. Every ConfigBox moved verbatim with its props, children and isAdmin guard intact; the only edit inside one is Prettier rewrapping an arrow function that shifted indentation level. Tabs are derived from a filtered array rather than conditional JSX, so indices stay correct when admin-only tabs drop out. Nine of the thirteen sections are admin-gated, which leaves a non-admin four sections in General alone -- the bar is hidden entirely in that case rather than showing four empty tabs. Panels stay mounted and toggle with `hidden`. Conditionally rendering them would unmount inputs belonging to the shared react-hook-form, and since Save is disabled on !isValid, a partial tree can compute a stale isValid and lock saving. The active tab lives in the URL as ?tab= so reloads and shared links land in the right place, following the useSearchParams pattern in StatusPage/Status. An unknown tab, or an admin tab requested by a non-admin, falls back to the first visible tab. Both confirmation dialogs and the sticky save bar stay outside the panels: the dialogs would unmount mid-request otherwise, and the save bar serves the whole form. Because the save bar lists validation errors by field name, an error could name a field on a hidden tab. onError now switches to the tab owning the first error before the alert renders. Two titles are corrected to sentence case while their sections move: "Global Thresholds" and "Monitor IP/URL on Status Page". Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Pages/Settings/index.tsx | 870 ++++++++++++++++------------ client/src/locales/en.json | 11 +- 2 files changed, 497 insertions(+), 384 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 70a8e1bb6..564cb3bda 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,33 @@ 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"]; + +// Maps a validation error back to the tab holding it, so a failed save can +// reveal the offending field instead of naming one on a hidden tab. +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"); export const SettingsPage = () => { const theme = useTheme(); @@ -53,6 +80,28 @@ 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] + ); + + // An unknown tab, or an admin tab requested by a non-admin, falls back to the + // first tab the current user can actually see. + const requestedTab = searchParams.get("tab"); + const activeTab: SettingsTabKey = + visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? visibleTabs[0].key; + + const handleTabChange = (tab: SettingsTabKey) => { + const updated = new URLSearchParams(searchParams); + if (tab === visibleTabs[0].key) { + updated.delete("tab"); + } else { + updated.set("tab", tab); + } + setSearchParams(updated, { replace: true }); + }; // Local state for demo monitors dialog const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); @@ -298,6 +347,15 @@ export const SettingsPage = () => { const onError = (errors: unknown) => { logger.debug("Form validation errors", errors); + // The save bar lists errors by field name, so reveal the tab owning the + // first one rather than naming a field the user cannot see. + const firstField = Object.keys(errors ?? {})[0]; + if (firstField) { + const target = tabForField(firstField); + if (visibleTabs.some((tab) => tab.key === target)) { + handleTabChange(target); + } + } }; const languages = Object.keys(i18n.options.resources || {}); @@ -324,7 +382,26 @@ export const SettingsPage = () => { component="form" onSubmit={form.handleSubmit(onSubmit, onError)} > - + {visibleTabs.length > 1 && ( + handleTabChange(value)} + > + {visibleTabs.map((tab) => ( + + ))} + + )} + + {/* general tab */} + } /> + {/* URL Settings */} + + } + /> + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + + {/* monitoring tab */} + + } + /> + )} + {/* 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}` + } + /> + } + /> + )} + + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + + + {/* email tab */} + + } + 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 */} + - {/* URL Settings */} - - } - /> - + {/* data tab */} + + } + /> + )} {/* Clear All Stats */} {isAdmin && ( { /> )} - {/* Check Retention */} + {/* Remove All Monitors - Admin Only */} {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}` - } - /> - } - /> - )} - - {/* 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}`} - - - )} - - ); - })()} - - } - 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 */} - - - - - } - /> - )} - - {/* Demo 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 */} Date: Fri, 28 Aug 2026 21:36:10 -0400 Subject: [PATCH 2/5] fix(client): render settings tab panels conditionally Code review caught that the tab panels never hid. Each panel was a ` - } - /> - - - {/* monitoring tab */} - + )} {/* email tab */} - + )} + + {/* integrations tab */} + {activeTab === "integrations" && ( + + {isAdmin && ( + 0 ? ( + + + {globalProxyEnabled && ( + + )} + + ) : ( + + ) + } + /> + )} + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + + )} + + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + - - - ) : ( - - )} - - {/* TLS Servername (Optional) */} - )} - /> - - {/* Connection Host (Optional) */} - - - {/* Boolean Switches */} - - {emailSwitches.map(({ name, label }) => ( - - ))} - - - {/* Test Email Button */} - - - - - } - /> - )} - - - {/* integrations tab */} - + + } + /> + )} + + )} {/* data tab */} - - } - /> - )} - {/* Clear All Stats */} - {isAdmin && ( - setIsStatsDialogOpen(true)} - > - {t("common.buttons.clear")} - - } - /> - )} - - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - + + + + + } + /> + )} + {/* Clear All Stats */} + {isAdmin && ( + setIsDemoMonitorsDialogOpen(true)} + onClick={() => setIsStatsDialogOpen(true)} > - {t("common.buttons.removeMonitors")} + {t("common.buttons.clear")} - - } - /> - )} - + } + /> + )} + + {/* Remove All Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + + )} {/* Clear Stats Confirmation Dialog */} { return (
  • - {field}: {message} + revealField(field)} + > + {field} + + : {message}
  • ); From 09d20c1bc778748a1c3a14e71a3ad17292c74126 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 21:45:56 -0400 Subject: [PATCH 3/5] fix(client): show usable field names in the settings error list Second review pass on the tab grouping. The save bar listed raw schema keys, and react-hook-form nests errors by path, so a threshold error arrived as { globalThresholds: { cpu: {...} } }. Iterating only the top level produced an entry whose own message was undefined, rendering "globalThresholds: Invalid value" -- naming none of the four sliders and carrying no reason. Errors are now flattened to their leaves, so the row names the field that actually failed. Rows also showed untranslated identifiers such as systemEmailRejectUnauthorized. Every field already owns a translated label, so those are reused via FIELD_LABEL_KEY; no new strings. Labels that append an explanation after a dash are trimmed to the name. Both defects predate this branch, but the tab work promoted these rows into clickable links, which made unusable names more prominent. Also guards visibleTabs[0], which is safe only because "general" is currently ungated: gating it later, or adding a second role, would throw on an empty list and blank the page. And a "tab" parameter that does not resolve -- an unknown key, or an admin tab followed by a non-admin -- is now dropped, so the URL stops claiming a tab the page is not showing. Valid deep links are untouched. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Pages/Settings/index.tsx | 90 +++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e7172638e..6230e0285 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -74,6 +74,60 @@ const tabForField = (field: string): SettingsTabKey => ? "email" : (FIELD_TAB[field.split(".")[0]] ?? "general"); +// Error rows named the raw schema key ("systemEmailRejectUnauthorized"). Each +// field already owns a translated label, so reuse those rather than adding +// strings. Labels that append an explanation after a dash are trimmed to the +// name itself. +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", +}; + +// react-hook-form nests errors by path, so a grouped field arrives as +// { globalThresholds: { cpu: { message } } }. Iterating the top level alone +// yields an entry whose own message is undefined, which renders as a bare +// "globalThresholds" naming none of the four sliders. Walk to the leaves. +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]) => { + 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(); const { t, i18n } = useTranslation(); @@ -90,18 +144,33 @@ export const SettingsPage = () => { // An unknown tab, or an admin tab requested by a non-admin, falls back to the // first tab the current user can actually see. const requestedTab = searchParams.get("tab"); + // visibleTabs always contains "general" today; the constant fallback keeps a + // future gating change from throwing on an empty list. + const firstVisibleTab: SettingsTabKey = visibleTabs[0]?.key ?? SETTINGS_TABS[0].key; const activeTab: SettingsTabKey = - visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? visibleTabs[0].key; + visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? firstVisibleTab; const handleTabChange = (tab: SettingsTabKey) => { const updated = new URLSearchParams(searchParams); - if (tab === visibleTabs[0].key) { + if (tab === firstVisibleTab) { updated.delete("tab"); } else { updated.set("tab", tab); } setSearchParams(updated, { replace: true }); }; + + // A "tab" that does not resolve -- an unknown key, or an admin tab followed by + // a non-admin -- leaves the URL claiming a tab the page is not showing. Drop it + // so the address bar matches what is rendered. + useEffect(() => { + if (requestedTab !== null && requestedTab !== activeTab) { + const updated = new URLSearchParams(searchParams); + updated.delete("tab"); + setSearchParams(updated, { replace: true }); + } + }, [requestedTab, activeTab, searchParams, setSearchParams]); + // Local state for demo monitors dialog const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); @@ -1027,24 +1096,23 @@ export const SettingsPage = () => { 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 ( -
  • +
  • revealField(field)} + onClick={() => revealField(path)} > - {field} + {label} - : {message} + {message ? `: ${message}` : ""}
  • ); From b24e42e51691eef3e8ca3a0f31e86e0d007f3f1c Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 23:06:27 -0400 Subject: [PATCH 4/5] fix(client): align the validation alert with the save button Browser testing showed the alert stretching the full width of the save bar while Save was pushed against the right edge, and the two sat on different vertical centres. The alert carried flexGrow: 1, so it consumed all the free space in the row, plus mb: 2 and mr: 2, which offset it against a button that had neither. The row itself set no alignItems, so the items aligned by stretch rather than centre. The row now centres its items and uses gap for spacing, and the alert drops its sx entirely: growing was what stretched it, and the margins are the gap's job. Measured: the alert sizes to its content (297px, was 1168px), 16px from Save, centres within 2px. Removing the sx also clears two of the four pre-existing lint warnings on this file. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Pages/Settings/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 6230e0285..c5cd8f7c2 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1073,6 +1073,8 @@ export const SettingsPage = () => { { > {/* Validation Error Display */} {Object.keys(form.formState.errors).length > 0 && ( - + Date: Mon, 31 Aug 2026 10:17:00 -0700 Subject: [PATCH 5/5] add localizations, exclude ref from recursion --- client/src/Pages/Settings/index.tsx | 40 ++++++++++------------------- client/src/locales/ar.json | 7 +++++ client/src/locales/ca.json | 7 +++++ client/src/locales/cs.json | 7 +++++ client/src/locales/de.json | 7 +++++ client/src/locales/es.json | 7 +++++ client/src/locales/fi.json | 7 +++++ client/src/locales/fr.json | 7 +++++ client/src/locales/it.json | 7 +++++ client/src/locales/ja.json | 7 +++++ client/src/locales/pl.json | 7 +++++ client/src/locales/pt-BR.json | 7 +++++ client/src/locales/ru.json | 7 +++++ client/src/locales/th.json | 7 +++++ client/src/locales/tr.json | 7 +++++ client/src/locales/uk.json | 7 +++++ client/src/locales/vi.json | 7 +++++ client/src/locales/zh-CN.json | 7 +++++ client/src/locales/zh-TW.json | 7 +++++ 19 files changed, 140 insertions(+), 26 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index c5cd8f7c2..c8fccfd3f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -58,8 +58,6 @@ const SETTINGS_TABS = [ type SettingsTabKey = (typeof SETTINGS_TABS)[number]["key"]; -// Maps a validation error back to the tab holding it, so a failed save can -// reveal the offending field instead of naming one on a hidden tab. const FIELD_TAB: Record = { checkTTL: "monitoring", globalThresholds: "monitoring", @@ -74,10 +72,6 @@ const tabForField = (field: string): SettingsTabKey => ? "email" : (FIELD_TAB[field.split(".")[0]] ?? "general"); -// Error rows named the raw schema key ("systemEmailRejectUnauthorized"). Each -// field already owns a translated label, so reuse those rather than adding -// strings. Labels that append an explanation after a dash are trimmed to the -// name itself. const FIELD_LABEL_KEY: Record = { checkTTL: "pages.settings.form.retention.option.days.label", "globalThresholds.cpu": "pages.settings.form.thresholds.option.cpu.label", @@ -104,10 +98,6 @@ const FIELD_LABEL_KEY: Record = { "pages.settings.form.email.option.rejectUnauthorized.label", }; -// react-hook-form nests errors by path, so a grouped field arrives as -// { globalThresholds: { cpu: { message } } }. Iterating the top level alone -// yields an entry whose own message is undefined, which renders as a bare -// "globalThresholds" naming none of the four sliders. Walk to the leaves. type FieldErrorLeaf = { path: string; message?: string }; const flattenFieldErrors = (errors: unknown, prefix = ""): FieldErrorLeaf[] => { @@ -115,6 +105,9 @@ const flattenFieldErrors = (errors: unknown, prefix = ""): FieldErrorLeaf[] => { 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 []; @@ -141,11 +134,7 @@ export const SettingsPage = () => { [isAdmin] ); - // An unknown tab, or an admin tab requested by a non-admin, falls back to the - // first tab the current user can actually see. const requestedTab = searchParams.get("tab"); - // visibleTabs always contains "general" today; the constant fallback keeps a - // future gating change from throwing on an empty list. const firstVisibleTab: SettingsTabKey = visibleTabs[0]?.key ?? SETTINGS_TABS[0].key; const activeTab: SettingsTabKey = visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? firstVisibleTab; @@ -160,16 +149,19 @@ export const SettingsPage = () => { setSearchParams(updated, { replace: true }); }; - // A "tab" that does not resolve -- an unknown key, or an admin tab followed by - // a non-admin -- leaves the URL claiming a tab the page is not showing. Drop it - // so the address bar matches what is rendered. + // 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 && requestedTab !== activeTab) { - const updated = new URLSearchParams(searchParams); - updated.delete("tab"); - setSearchParams(updated, { replace: true }); + if (requestedTab === null) { + return; } - }, [requestedTab, activeTab, searchParams, setSearchParams]); + 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); @@ -418,10 +410,6 @@ export const SettingsPage = () => { logger.debug("Form validation errors", errors); }; - // The save bar lists errors by field name, and the offending field may sit on - // a tab that is not open. Submitting cannot reveal it -- the button is - // disabled while the form is invalid -- so each listed field switches to its - // own tab instead. const revealField = (field: string) => { const target = tabForField(field); if (visibleTabs.some((tab) => tab.key === target)) { diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 55f34650d..f1153737f 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -1141,6 +1141,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..5ac20610e 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -1141,6 +1141,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..2896413fa 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -1141,6 +1141,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..de517b57c 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1141,6 +1141,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/es.json b/client/src/locales/es.json index 589399a83..24fd69302 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -1141,6 +1141,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..3ffb1acc3 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -1141,6 +1141,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..94f723074 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -1141,6 +1141,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..af743e7d2 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -1237,6 +1237,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..617d06fdf 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1141,6 +1141,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..2298ea96c 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -1207,6 +1207,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..7b85a43d4 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -1141,6 +1141,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..7f1824793 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -1141,6 +1141,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..f3b535f86 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -1141,6 +1141,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..f70f18511 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -1141,6 +1141,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..73b10decc 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -1141,6 +1141,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..b04b5de1e 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -1141,6 +1141,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..e93707729 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1142,6 +1142,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..a7da9b80b 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -1141,6 +1141,13 @@ } }, "settings": { + "tabs": { + "general": "一般", + "monitoring": "監控", + "email": "郵件", + "integrations": "整合", + "data": "資料" + }, "form": { "timezone": { "title": "顯示時區",