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/locales/ar.json b/client/src/locales/ar.json index 44e849fbc..82015e849 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -1147,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 541230c14..912c5b198 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -1147,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 7ca4d84e9..1d98d565a 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -1147,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 2a8ff08f1..6a39ee1aa 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1147,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 c4267a13a..c6a2da621 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1339,6 +1339,13 @@ } }, "settings": { + "tabs": { + "general": "General", + "monitoring": "Monitoring", + "email": "Email", + "integrations": "Integrations", + "data": "Data" + }, "form": { "timezone": { "title": "Display timezone", @@ -1396,7 +1403,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": { @@ -1428,7 +1435,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 154c9eb58..ac7e47c83 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -1147,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 a25f9211c..53bb68fbc 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -1147,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 fe967457c..da3f965be 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -1147,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 4d931a759..4f4714675 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -1243,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 215bffc65..cf05c64b0 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1147,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 617fac273..5afb59748 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -1213,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 7650f34b0..225c212a2 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -1147,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 fca859a26..ff9960ebe 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -1147,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 071c8b12e..e6a0d8389 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -1147,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 10a855ba2..dc0f3444c 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -1147,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 99c8b9e9d..807a9b2d0 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -1147,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 0f2903793..899c8cb0c 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -1147,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 e9b0d251c..959d09f38 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1148,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 102082d49..16849cc6e 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -1147,6 +1147,13 @@ } }, "settings": { + "tabs": { + "general": "一般", + "monitoring": "監控", + "email": "郵件", + "integrations": "整合", + "data": "資料" + }, "form": { "timezone": { "title": "顯示時區",