From 67defeaa32a43e7660a96dc31f2db6168b968d73 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 14 Jun 2026 00:02:48 +0100 Subject: [PATCH] Start localization foundation for settings journey --- ...-product-surfaces-foundation-2026-06-13.md | 30 +++ docs/release-control/v6/internal/status.json | 27 ++- .../subsystems/frontend-primitives.md | 24 ++- .../internal/subsystems/security-privacy.md | 13 +- .../Settings/DockerRuntimeSettingsCard.tsx | 20 +- .../Settings/GeneralSettingsPanel.tsx | 77 ++++---- .../GeneralSettingsPanel.guardrails.test.ts | 44 ++++- ...GeneralSettingsPanel.localization.test.tsx | 54 +++++- .../__tests__/settingsArchitecture.test.ts | 18 +- .../src/i18n/__tests__/i18n.test.ts | 74 +++++++ frontend-modern/src/i18n/index.ts | 20 +- frontend-modern/src/i18n/locales.ts | 46 ++++- frontend-modern/src/i18n/messages.ts | 180 +++++++++++++++++- frontend-modern/src/i18n/policy.ts | 47 +++++ .../systemSettingsPresentation.test.ts | 36 +++- .../src/utils/systemSettingsPresentation.ts | 86 +++++++++ 16 files changed, 709 insertions(+), 87 deletions(-) create mode 100644 docs/release-control/v6/internal/records/localized-product-surfaces-foundation-2026-06-13.md create mode 100644 frontend-modern/src/i18n/policy.ts diff --git a/docs/release-control/v6/internal/records/localized-product-surfaces-foundation-2026-06-13.md b/docs/release-control/v6/internal/records/localized-product-surfaces-foundation-2026-06-13.md new file mode 100644 index 000000000..83be5abb0 --- /dev/null +++ b/docs/release-control/v6/internal/records/localized-product-surfaces-foundation-2026-06-13.md @@ -0,0 +1,30 @@ +# Localized Product Surfaces Foundation - 2026-06-13 + +## Slice + +- Claimed governed slice: `candidate-lane:localized-product-surfaces`. +- Migrated journey: Settings -> System -> General language preference, including the Settings shell/header/navigation copy already on the catalog path, Appearance language selection, anonymous telemetry controls, Docker / Podman update-action controls, and monitoring cadence copy. +- First-wave locales: German (`de`) and Spanish (`es`). + +## Evidence + +- `frontend-modern/src/i18n/locales.ts` owns locale normalization, supported locale registry metadata, first-wave locale declaration, and English fallback-chain behavior. +- `frontend-modern/src/i18n/messages.ts` owns the English source catalog plus complete German and Spanish first-wave entries for the migrated Settings General journey. +- `frontend-modern/src/i18n/policy.ts` records catalog shape and non-translation rules for API fields, config keys, commands, logs/payloads, resource names, user-entered identifiers, and product/integration identifiers. +- `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` and `DockerRuntimeSettingsCard.tsx` now render migrated Settings General copy through the i18n owner layer instead of page-local English literals. +- Guardrails cover catalog completeness, unsupported-locale fallback, explicit first-wave translations for the migrated journey, stable machine-facing tokens (`API`, `CPU`, `IP`, `JSON`, env vars, `Pulse`, `Proxmox VE`, `Docker / Podman`, and `"Update"`), and source checks that fail if the migrated Settings General copy returns to hardcoded English. + +## Verification + +- `npm --prefix repos/pulse/frontend-modern test -- --run src/i18n/__tests__/i18n.test.ts src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts src/utils/__tests__/systemSettingsPresentation.test.ts src/components/Settings/__tests__/settingsLocalization.test.ts` passed: 5 files, 35 tests. +- `npm --prefix repos/pulse/frontend-modern test -- --run src/components/Settings/__tests__/settingsArchitecture.test.ts src/i18n/__tests__/i18n.test.ts` passed: 2 files, 50 tests. +- `npm --prefix repos/pulse/frontend-modern run type-check` passed. +- `python3 repos/pulse/scripts/release_control/status_audit.py --pretty` passed with no errors. +- `python3 repos/pulse/scripts/release_control/control_plane_audit.py --check` passed with no errors or warnings. +- Browser proof used the managed dev server at `http://127.0.0.1:5173/settings/system-general`. English Settings General loaded without an auth blocker. Switching to Spanish rendered `Ajustes`, `Apariencia`, `Datos de uso y privacidad`, `Telemetría saliente anónima`, `Vista previa del payload`, `Actualizaciones de Docker / Podman`, and `Cadencia de supervisión`, while preserving `Docker / Podman`, `"Update"`, `API`, `CPU`, and `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true`. Switching to German rendered `Einstellungen`, `Darstellung`, `Nutzungsdaten und Datenschutz`, `Anonyme ausgehende Telemetrie`, `Payload anzeigen`, `Docker / Podman-Updates`, and `Monitoring-Takt`, while preserving the same machine-facing tokens. + +## Residuals + +- The broader localized-product-surfaces lane remains open. This slice does not translate website/acquisition copy, install and troubleshooting docs, setup wizard/first-run pages outside Settings General, core monitoring tables and alerts/operator-action copy, mobile handoff copy, commercial trust surfaces, or public checkout/account surfaces. +- Locale QA remains local guardrail coverage only for the migrated frontend-modern journey. Broader screenshot review, native speaker review, pseudo-locale coverage, extraction tooling, and future French/Brazilian Portuguese/Japanese/Simplified Chinese/Korean expansion remain separate governed work. +- Machine-facing payloads, log text, config keys, API fields, commands, source resource names, and user-entered identifiers remain intentionally untranslated. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 9290f0f13..2bfc35037 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6929,11 +6929,31 @@ "proposed_resolution": "new-lane", "coverage_impact": 8, "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/localized-product-surfaces-foundation-2026-06-13.md", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/subsystems/frontend-primitives.md", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/Settings/settingsNavCatalog.ts", @@ -6943,6 +6963,11 @@ "repo": "pulse", "path": "frontend-modern/src/i18n/messages.ts", "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/i18n/policy.ts", + "kind": "file" } ] } @@ -6952,7 +6977,7 @@ "id": "localized-product-surfaces", "name": "Localized Product Surfaces", "summary": "Promote localization into a governed product lane covering i18n architecture, locale fallback and QA, German and Spanish first-wave translations, website and docs localization, first-run/settings/core monitoring/alerts copy, mobile handoff copy, commercial trust surfaces, and guardrails that keep machine-facing identifiers, commands, config keys, API fields, logs, and resource names untranslated.", - "status": "proposed", + "status": "planned", "recorded_at": "2026-06-12", "target_id": "v6-product-lane-expansion", "current_lane_ids": [ diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4a6f16300..35dd3afb4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -186,15 +186,21 @@ work extends shared components instead of creating new local variants. Frontend localization is a shared primitive boundary. Locale support must flow through typed message catalogs with an English fallback and explicit seed -locale coverage rather than page-local string switches. The active app locale -is a shared user preference initialized from stored or browser language and -exposed through Settings > General; individual surfaces must consume that -shared preference instead of creating local language toggles. Customer-facing -shell, navigation, settings, first-run, empty-state, and alert copy may be -localized, but machine-facing values must remain stable: commands, environment -variables, API fields, config keys, log lines, error codes, hostnames, resource -names, and vendor object names stay untranslated unless the owning runtime -contract explicitly says otherwise. +locale coverage rather than page-local string switches. +`frontend-modern/src/i18n/locales.ts` owns locale normalization, the supported +locale registry, and fallback chains; `frontend-modern/src/i18n/messages.ts` +owns the typed catalog shape; and `frontend-modern/src/i18n/policy.ts` owns the +first-wave non-translatable token rules. The active app locale is a shared user +preference initialized from stored or browser language and exposed through +Settings > General; individual surfaces must consume that shared preference +instead of creating local language toggles. Customer-facing shell, navigation, +settings, first-run, empty-state, and alert copy may be localized, but +machine-facing values must remain stable: commands, environment variables, API +fields, config keys, log lines, error codes, hostnames, resource names, product +identifiers, and vendor object names stay untranslated unless the owning +runtime contract explicitly says otherwise. Migrated settings surfaces must +render customer-facing copy through the catalog and shared presentation helpers +rather than reintroducing panel-local English. Alert thresholds consume the shared FilterBar primitive and route state, while the alerts subsystem owns the resource data and platform-specific threshold diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index f86af8be2..8ba69a30b 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -92,7 +92,12 @@ controls as normal product settings. 6. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `frontend-primitives`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary. 7. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `frontend-primitives`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary. Privacy documentation links compose `ExternalTextLink`; security-privacy - owns the telemetry/privacy meaning and retention copy. + owns the telemetry/privacy meaning and retention copy. Localized settings + copy for this surface may route through `frontend-modern/src/i18n/messages.ts` + and `frontend-modern/src/i18n/policy.ts`, but translation must preserve the + governed privacy guarantees and leave machine-facing tokens such as + `PULSE_TELEMETRY`, API fields, config keys, commands, logs, and product or + source identifiers untranslated. 8. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `frontend-primitives`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. 9. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `frontend-primitives`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. Security guide links compose `ExternalTextLink`; security-privacy owns the @@ -158,7 +163,7 @@ controls as normal product settings. email can support display, delivery, or migration, but request access must match the authenticated principal against stored `OwnerUserID` or member `UserID`. -5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/APIAccessPanel.tsx`, `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`, `frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`, `frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`, `frontend-modern/src/components/Settings/SSOProviderTypeIcon.tsx`, `frontend-modern/src/constants/apiScopes.ts`, `frontend-modern/src/utils/apiTokenPresentation.ts`, `frontend-modern/src/utils/securityAuthPresentation.ts`, `frontend-modern/src/utils/securityScorePresentation.ts`, `frontend-modern/src/utils/auditLogPresentation.ts`, and `frontend-modern/src/utils/auditWebhookPresentation.ts` boundary. +5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/APIAccessPanel.tsx`, `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`, `frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`, `frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`, `frontend-modern/src/components/Settings/SSOProviderTypeIcon.tsx`, `frontend-modern/src/constants/apiScopes.ts`, `frontend-modern/src/utils/apiTokenPresentation.ts`, `frontend-modern/src/utils/securityAuthPresentation.ts`, `frontend-modern/src/utils/securityScorePresentation.ts`, `frontend-modern/src/utils/auditLogPresentation.ts`, `frontend-modern/src/utils/auditWebhookPresentation.ts`, and the localized catalog/policy boundary in `frontend-modern/src/i18n/`. 6. Change operator-facing telemetry/adoption reporting through `scripts/telemetry_adoption_report.py` together with the privacy disclosure whenever release-identity interpretation changes. 7. Change data-at-rest encryption-key or control-plane magic-link HMAC key and storage-root hardening semantics through `internal/crypto/crypto.go`, `internal/cloudcp/auth/magiclink.go`, `internal/cloudcp/auth/magiclink_store.go`, and `internal/securityutil/secure_storage_dir.go` together so writable-but-not-owned runtime storage mounts stay supported without weakening file-level secrecy. 8. Change auth-env password normalization, hosted commercial base URL @@ -209,6 +214,10 @@ controls as normal product settings. 3. Keep shared frontend settings proof routing aligned whenever security/privacy presentation changes. 4. Keep the checked-in telemetry adoption report aligned with the same release-identity rules used by the runtime telemetry payload. 5. Update this contract whenever a new canonical security, token, auth, or privacy surface becomes part of the governed trust boundary. +5a. Keep localized privacy and telemetry settings copy covered by catalog + completeness, fallback, and non-translatable-token tests so translated + surfaces cannot weaken the governed privacy disclosure or turn machine + identifiers into localized prose. 6. Keep Security Overview and Resource Privacy/Data Handling loading placeholders on the shared `SettingsLoadingSkeleton` primitive. This subsystem owns the security/privacy posture semantics; frontend-primitives diff --git a/frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx b/frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx index e673d2227..e8d64b80d 100644 --- a/frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx +++ b/frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx @@ -4,10 +4,7 @@ import { TogglePrimitive } from '@/components/shared/Toggle'; import { ENVIRONMENT_LOCK_BUTTON_TITLE } from '@/utils/environmentLockPresentation'; import { DOCKER_UPDATE_ACTIONS_ENV_VAR, - DOCKER_UPDATE_ACTIONS_SECTION_DESCRIPTION, - DOCKER_UPDATE_ACTIONS_SECTION_TITLE, - DOCKER_UPDATE_ACTIONS_TOGGLE_DESCRIPTION, - DOCKER_UPDATE_ACTIONS_TOGGLE_LABEL, + getDockerUpdateActionsPresentation, } from '@/utils/systemSettingsPresentation'; interface DockerRuntimeSettingsCardProps { @@ -22,16 +19,19 @@ export const DockerRuntimeSettingsCard: Component

- {DOCKER_UPDATE_ACTIONS_SECTION_TITLE} + {getDockerUpdateActionsPresentation().sectionTitle}

-

{DOCKER_UPDATE_ACTIONS_SECTION_DESCRIPTION}

+

{getDockerUpdateActionsPresentation().sectionDescription}

- - {DOCKER_UPDATE_ACTIONS_TOGGLE_LABEL} + + {getDockerUpdateActionsPresentation().toggleLabel}

- {DOCKER_UPDATE_ACTIONS_TOGGLE_DESCRIPTION} + {getDockerUpdateActionsPresentation().toggleDescription}

- Can also be set via environment variable:{' '} + {getDockerUpdateActionsPresentation().environmentHint}{' '} {DOCKER_UPDATE_ACTIONS_ENV_VAR}=true diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index bb2bf3e68..f199b3d87 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -25,7 +25,9 @@ import { layoutStore } from '@/utils/layout'; import { PVE_POLLING_MAX_SECONDS, PVE_POLLING_MIN_SECONDS, - PVE_POLLING_PRESETS, + getPvePollingCadenceSummary, + getPvePollingCustomOption, + getPvePollingPresetOptions, } from '@/utils/systemSettingsPresentation'; import { PRIVACY_DOC_URL } from '@/utils/docsLinks'; @@ -48,12 +50,12 @@ const TEMPERATURE_UNIT_OPTIONS: FilterOption<'celsius' | 'fahrenheit'>[] = [ { value: 'fahrenheit', label: 'Fahrenheit' }, ]; -const PVE_POLLING_OPTIONS: FilterOption[] = [ - ...PVE_POLLING_PRESETS.map((option) => ({ - label: option.label, - value: option.value, - })), - { value: 'custom', label: 'Custom' }, +const TELEMETRY_ENV_VAR = 'PULSE_TELEMETRY'; +const PVE_POLLING_INTERVAL_ENV_VAR = 'PVE_POLLING_INTERVAL'; + +const getPvePollingOptions = (): FilterOption[] => [ + ...getPvePollingPresetOptions(), + getPvePollingCustomOption(), ]; export interface GeneralSettingsPanelProps { @@ -214,8 +216,8 @@ export const GeneralSettingsPanel: Component = (props {/* Usage Data + Privacy Card */} @@ -224,21 +226,16 @@ export const GeneralSettingsPanel: Component = (props

- Anonymous outbound telemetry + {t('settings.general.telemetry.title')} - +

- Help improve Pulse by sharing anonymous outbound usage data: a rotating install ID, - normalized release identity, runtime platform, aggregate self-hosted adoption - counts, and coarse feature flags. No hostnames, credentials, infrastructure - identifiers, prompts, chat messages, or personal information are sent. Telemetry - rows are retained for up to 90 days, and IP addresses are not stored in telemetry - rows.{' '} + {t('settings.general.telemetry.description')}{' '} - Full details + {t('settings.general.telemetry.fullDetails')}

@@ -257,7 +254,9 @@ export const GeneralSettingsPanel: Component = (props disabled={props.loadingTelemetryPreview()} onClick={() => void props.handleLoadTelemetryPreview()} > - {props.telemetryPreview() ? 'Refresh payload' : 'Preview payload'} + {props.telemetryPreview() + ? t('settings.general.telemetry.refreshPayload') + : t('settings.general.telemetry.previewPayload')}
@@ -282,17 +281,16 @@ export const GeneralSettingsPanel: Component = (props

- Current heartbeat payload + {t('settings.general.telemetry.payloadTitle')}

- Telemetry is currently disabled. This preview shows the payload Pulse would send - if you enable it. + {t('settings.general.telemetry.disabledPreview')}

                 {props.telemetryPreviewPayload()}
@@ -311,8 +309,8 @@ export const GeneralSettingsPanel: Component = (props
 
       {/* Monitoring Cadence Card */}
       
@@ -320,17 +318,10 @@ export const GeneralSettingsPanel: Component = (props
           

- Current cadence: {props.pvePollingInterval()} seconds ( - {props.pvePollingInterval() >= 60 - ? `${(props.pvePollingInterval() / 60).toFixed( - props.pvePollingInterval() % 60 === 0 ? 0 : 1, - )} minute${props.pvePollingInterval() / 60 === 1 ? '' : 's'}` - : 'under a minute'} - ) + {getPvePollingCadenceSummary(props.pvePollingInterval())}

- Shorter intervals provide near-real-time updates at the cost of higher API and CPU - usage on each node. Set a longer interval to reduce load on busy clusters. + {t('settings.general.monitoringCadence.description')}

@@ -338,7 +329,7 @@ export const GeneralSettingsPanel: Component = (props {/* Preset buttons */} = (props

- Enter seconds ({PVE_POLLING_MIN_SECONDS}-{PVE_POLLING_MAX_SECONDS}). Applies - to all clusters. + {t('settings.general.monitoringCadence.custom.description', { + min: PVE_POLLING_MIN_SECONDS, + max: PVE_POLLING_MAX_SECONDS, + })}

= (props - Managed via environment variable PVE_POLLING_INTERVAL. + {t('settings.general.monitoringCadence.envLocked', { + envVar: PVE_POLLING_INTERVAL_ENV_VAR, + })}
diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts index f418baa5a..cc9e5cffe 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts @@ -1,7 +1,22 @@ import { describe, expect, it } from 'vitest'; import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw'; +import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw'; +import { EN_MESSAGES } from '@/i18n/messages'; -const normalizedGeneralSettingsPanelSource = generalSettingsPanelSource.replace(/\s+/g, ' '); +const migratedGeneralSettingsCopy = [ + 'Usage data and privacy', + 'Anonymous outbound telemetry', + 'Preview payload', + 'Refresh payload', + 'Reset ID', + 'Copy JSON', + 'Current heartbeat payload', + 'Telemetry payload preview', + 'Monitoring cadence', + 'Current cadence:', + 'Custom polling interval', + 'Managed via environment variable', +]; describe('GeneralSettingsPanel guardrails', () => { it('routes telemetry disclosure through the shipped privacy doc URL', () => { @@ -12,10 +27,10 @@ describe('GeneralSettingsPanel guardrails', () => { }); it('keeps telemetry preview and reset controls in the general settings panel', () => { - expect(generalSettingsPanelSource).toContain('Usage data and privacy'); - expect(generalSettingsPanelSource).toContain('Preview payload'); - expect(generalSettingsPanelSource).toContain('Reset ID'); - expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.section.title'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.previewPayload'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.resetId'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.payloadAriaLabel'); }); it('routes telemetry command controls through the shared Button primitive', () => { @@ -35,9 +50,24 @@ describe('GeneralSettingsPanel guardrails', () => { }); it('summarizes telemetry retention and non-storage guarantees in-product', () => { - expect(normalizedGeneralSettingsPanelSource).toContain('retained for up to 90 days'); - expect(normalizedGeneralSettingsPanelSource).toContain( + expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( + 'retained for up to 90 days', + ); + expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( 'IP addresses are not stored in telemetry rows', ); }); + + it('prevents migrated general settings copy from reverting to hardcoded English', () => { + for (const copy of migratedGeneralSettingsCopy) { + expect(generalSettingsPanelSource).not.toContain(copy); + } + expect(generalSettingsPanelSource).toContain( + 'settings.general.monitoringCadence.section.title', + ); + expect(generalSettingsPanelSource).toContain('getPvePollingCadenceSummary'); + expect(dockerRuntimeSettingsCardSource).toContain('getDockerUpdateActionsPresentation'); + expect(dockerRuntimeSettingsCardSource).not.toContain('Docker / Podman updates'); + expect(dockerRuntimeSettingsCardSource).not.toContain('Hide update buttons'); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx index 693adb0f4..b639427dd 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -5,7 +5,7 @@ import { DEFAULT_LOCALE, getActiveLocale, setActiveLocale } from '@/i18n'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { GeneralSettingsPanel, type GeneralSettingsPanelProps } from '../GeneralSettingsPanel'; -function renderGeneralSettingsPanel() { +function renderGeneralSettingsPanel(overrides: Partial = {}) { const [themePreference, setThemePreference] = createSignal<'light' | 'dark' | 'system'>('system'); const [pvePollingInterval, setPVEPollingInterval] = createSignal(30); const [pvePollingSelection, setPVEPollingSelection] = createSignal(30); @@ -42,6 +42,7 @@ function renderGeneralSettingsPanel() { disableDockerUpdateActionsLocked: () => false, savingDockerUpdateActions: () => false, handleDisableDockerUpdateActionsChange: vi.fn(), + ...overrides, }; const result = render(() => ); @@ -68,6 +69,14 @@ describe('GeneralSettingsPanel localization', () => { expect( screen.getByText(/Commands, resource names, and API fields stay unchanged/), ).toBeInTheDocument(); + expect(screen.getByText('Usage data and privacy')).toBeInTheDocument(); + expect(screen.getByText('Anonymous outbound telemetry')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument(); + expect(screen.getByText('Monitoring cadence')).toBeInTheDocument(); + expect(screen.getByText('Current cadence: 30 seconds (under a minute)')).toBeInTheDocument(); + expect(screen.getByText('Docker / Podman updates')).toBeInTheDocument(); + expect(screen.getByText('PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'English' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Español' })).toBeInTheDocument(); @@ -82,6 +91,49 @@ describe('GeneralSettingsPanel localization', () => { expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de'); expect(screen.getByText('Darstellung')).toBeInTheDocument(); expect(screen.getByText('Sprache')).toBeInTheDocument(); + expect(screen.getByText('Nutzungsdaten und Datenschutz')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Payload anzeigen' })).toBeInTheDocument(); + expect(screen.getByText('Monitoring-Takt')).toBeInTheDocument(); + expect( + screen.getByText('Aktueller Takt: 30 Sekunden (unter einer Minute)'), + ).toBeInTheDocument(); + expect(screen.getByText('Docker / Podman-Updates')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Deutsch' })).toHaveAttribute('aria-pressed', 'true'); }); + + it('re-renders the same general settings journey in Spanish without translating identifiers', () => { + renderGeneralSettingsPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Español' })); + + expect(getActiveLocale()).toBe('es'); + expect(screen.getByText('Apariencia')).toBeInTheDocument(); + expect(screen.getByText('Idioma')).toBeInTheDocument(); + expect(screen.getByText('Datos de uso y privacidad')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Vista previa del payload' })).toBeInTheDocument(); + expect(screen.getByText('Cadencia de supervisión')).toBeInTheDocument(); + expect( + screen.getByText('Cadencia actual: 30 segundos (menos de un minuto)'), + ).toBeInTheDocument(); + expect(screen.getByText('Actualizaciones de Docker / Podman')).toBeInTheDocument(); + expect(screen.getByText(/API y CPU/)).toBeInTheDocument(); + expect(screen.getByText('PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true')).toBeInTheDocument(); + }); + + it('localizes telemetry preview state while preserving JSON as a machine-facing token', () => { + renderGeneralSettingsPanel({ + telemetryPreview: () => ({ enabled: false }) as never, + telemetryPreviewEnabled: () => false, + telemetryPreviewPayload: () => '{"install_id":"abc"}', + }); + + fireEvent.click(screen.getByRole('button', { name: 'Español' })); + + expect(screen.getByRole('button', { name: 'Actualizar payload' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copiar JSON' })).toBeInTheDocument(); + expect(screen.getByText('Payload de heartbeat actual')).toBeInTheDocument(); + expect(screen.getByLabelText('Vista previa del payload de telemetría')).toHaveTextContent( + '"install_id":"abc"', + ); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 5174aeeef..256373565 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import settingsSource from '../Settings.tsx?raw'; +import { EN_MESSAGES } from '@/i18n/messages'; import settingsDialogsSource from '../SettingsDialogs.tsx?raw'; import settingsPageShellSource from '../SettingsPageShell.tsx?raw'; import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw'; @@ -556,13 +557,17 @@ describe('settings architecture guardrails', () => { }); it('keeps telemetry disclosure aligned with the security privacy contract', () => { - expect(generalSettingsPanelSource).toContain('aggregate self-hosted adoption'); - expect(generalSettingsPanelSource).toContain('counts, and coarse feature flags'); - expect(generalSettingsPanelSource).toContain( + expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( + 'aggregate self-hosted adoption', + ); + expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( + 'counts, and coarse feature flags', + ); + expect(EN_MESSAGES['settings.general.telemetry.description']).toContain( 'identifiers, prompts, chat messages, or personal information are sent.', ); - expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); - expect(generalSettingsPanelSource).toContain('Reset ID'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.payloadAriaLabel'); + expect(generalSettingsPanelSource).toContain('settings.general.telemetry.resetId'); expect(generalSettingsPanelSource).not.toContain('license_tier'); expect(generalSettingsPanelSource).not.toContain('api_tokens'); }); @@ -758,7 +763,7 @@ describe('settings architecture guardrails', () => { }); it('keeps Docker and Podman update-action copy on the system settings presentation owner', () => { - expect(dockerRuntimeSettingsCardSource).toContain('DOCKER_UPDATE_ACTIONS_SECTION_TITLE'); + expect(dockerRuntimeSettingsCardSource).toContain('getDockerUpdateActionsPresentation'); expect(dockerRuntimeSettingsCardSource).toContain('DOCKER_UPDATE_ACTIONS_ENV_VAR'); expect(dockerRuntimeSettingsCardSource).toContain('TogglePrimitive'); expect(dockerRuntimeSettingsCardSource).not.toContain('role="switch"'); @@ -767,6 +772,7 @@ describe('settings architecture guardrails', () => { expect(dockerRuntimeSettingsCardSource).not.toContain('container update actions'); expect(systemSettingsPresentationSource).toContain("getSourcePlatformLabel('docker')"); + expect(systemSettingsPresentationSource).toContain('getDockerUpdateActionsPresentation'); expect(systemSettingsPresentationSource).toContain('DOCKER_UPDATE_ACTIONS_SECTION_TITLE'); expect(systemSettingsPresentationSource).toContain('DOCKER_UPDATE_ACTIONS_ENV_VAR'); }); diff --git a/frontend-modern/src/i18n/__tests__/i18n.test.ts b/frontend-modern/src/i18n/__tests__/i18n.test.ts index df5e34542..d94d34f8c 100644 --- a/frontend-modern/src/i18n/__tests__/i18n.test.ts +++ b/frontend-modern/src/i18n/__tests__/i18n.test.ts @@ -3,11 +3,18 @@ import { DEFAULT_LOCALE, FIRST_LOCALIZATION_LOCALES, I18N_MESSAGES, + LOCALIZATION_FOUNDATION, + LOCALIZED_SETTINGS_GENERAL_JOURNEY_KEYS, + NEVER_TRANSLATE_COPY_RULES, NEXT_LOCALIZATION_LOCALES, + SETTINGS_GENERAL_ALLOWED_IDENTICAL_TRANSLATIONS, + SETTINGS_GENERAL_NON_TRANSLATABLE_TOKENS, + SUPPORTED_LOCALE_REGISTRY, SUPPORTED_LOCALES, detectBrowserLocale, getActiveLocale, getInitialLocalePreference, + getLocaleFallbackChain, getStoredLocalePreference, normalizeLocale, setActiveLocale, @@ -26,6 +33,7 @@ describe('i18n foundation', () => { it('normalizes supported locales and regional aliases', () => { expect(normalizeLocale('de-DE')).toBe('de'); expect(normalizeLocale('es_MX')).toBe('es'); + expect(normalizeLocale('es_MX_traditional')).toBe('es'); expect(normalizeLocale('fr-FR')).toBe('en'); expect(normalizeLocale(undefined)).toBe('en'); }); @@ -70,6 +78,8 @@ describe('i18n foundation', () => { }); it('falls back to the English catalog for unsupported locales and interpolates params', () => { + expect(getLocaleFallbackChain('de-DE')).toEqual(['de', 'en']); + expect(getLocaleFallbackChain('fr-FR')).toEqual(['en']); expect(t('settings.shell.searchEmpty', { query: 'alerts' }, 'pt-BR')).toBe( 'No settings found for "alerts"', ); @@ -87,5 +97,69 @@ describe('i18n foundation', () => { expect(FIRST_LOCALIZATION_LOCALES).toEqual(['de', 'es']); expect(NEXT_LOCALIZATION_LOCALES).toEqual(['fr', 'pt-BR', 'ja', 'zh-Hans', 'ko']); expect(SUPPORTED_LOCALES).toEqual(['en', 'de', 'es']); + expect(SUPPORTED_LOCALE_REGISTRY.de).toMatchObject({ + label: 'Deutsch', + englishLabel: 'German', + fallbackLocale: 'en', + rolloutStage: 'first-wave', + }); + }); + + it('documents the frontend localization architecture and non-translation boundaries', () => { + expect(LOCALIZATION_FOUNDATION).toMatchObject({ + ownerLayer: 'frontend-modern/src/i18n', + defaultLocale: 'en', + firstWaveLocales: ['de', 'es'], + }); + expect(NEVER_TRANSLATE_COPY_RULES.join(' ')).toContain('environment variable names'); + expect(NEVER_TRANSLATE_COPY_RULES.join(' ')).toContain('Resource names'); + expect(SETTINGS_GENERAL_NON_TRANSLATABLE_TOKENS).toEqual( + expect.arrayContaining([ + 'Pulse', + 'Proxmox VE', + 'Docker / Podman', + 'API', + 'CPU', + 'IP', + 'JSON', + 'PULSE_TELEMETRY', + 'PVE_POLLING_INTERVAL', + 'PULSE_DISABLE_DOCKER_UPDATE_ACTIONS', + '"Update"', + ]), + ); + }); + + it('requires explicit first-wave translations for the migrated settings general journey', () => { + for (const locale of FIRST_LOCALIZATION_LOCALES) { + const allowedIdenticalKeys: ReadonlySet = new Set( + (SETTINGS_GENERAL_ALLOWED_IDENTICAL_TRANSLATIONS[locale] ?? + []) as readonly I18nMessageKey[], + ); + for (const key of LOCALIZED_SETTINGS_GENERAL_JOURNEY_KEYS) { + expect(I18N_MESSAGES[locale][key], `${locale}:${key}`).toBeTruthy(); + if (!allowedIdenticalKeys.has(key)) { + expect(I18N_MESSAGES[locale][key], `${locale}:${key}`).not.toBe(I18N_MESSAGES.en[key]); + } + } + } + }); + + it('keeps machine-facing identifiers unchanged in first-wave settings general catalog copy', () => { + for (const locale of FIRST_LOCALIZATION_LOCALES) { + expect(I18N_MESSAGES[locale]['settings.general.language.description']).toContain('API'); + expect(I18N_MESSAGES[locale]['settings.general.telemetry.description']).toContain('Pulse'); + expect(I18N_MESSAGES[locale]['settings.general.telemetry.description']).toContain('IP'); + expect(I18N_MESSAGES[locale]['settings.general.telemetry.copyJson']).toContain('JSON'); + expect( + t( + 'settings.general.monitoringCadence.envLocked', + { + envVar: 'PVE_POLLING_INTERVAL', + }, + locale, + ), + ).toContain('PVE_POLLING_INTERVAL'); + } }); }); diff --git a/frontend-modern/src/i18n/index.ts b/frontend-modern/src/i18n/index.ts index 64ebc3d44..8876bd97f 100644 --- a/frontend-modern/src/i18n/index.ts +++ b/frontend-modern/src/i18n/index.ts @@ -2,6 +2,7 @@ import { createSignal } from 'solid-js'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { DEFAULT_LOCALE, + getLocaleFallbackChain, normalizeLocale, resolveSupportedLocale, type SupportedLocale, @@ -106,8 +107,12 @@ export function translateMessage( params: MessageParams = {}, locale: string | null | undefined = activeLocaleSignal(), ): string { - const normalizedLocale = normalizeLocale(locale); - const template = I18N_MESSAGES[normalizedLocale][key] ?? EN_MESSAGES[key]; + const fallbackChain = getLocaleFallbackChain(locale); + const template = + fallbackChain.reduce( + (message, fallbackLocale) => message ?? I18N_MESSAGES[fallbackLocale][key], + undefined, + ) ?? EN_MESSAGES[key]; return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, paramKey: string) => { const value = params[paramKey]; return value === null || value === undefined ? match : String(value); @@ -121,11 +126,22 @@ export { I18N_MESSAGES } from './messages'; export { DEFAULT_LOCALE, FIRST_LOCALIZATION_LOCALES, + getLocaleFallbackChain, NEXT_LOCALIZATION_LOCALES, + SUPPORTED_LOCALE_REGISTRY, SUPPORTED_LOCALE_LABELS, SUPPORTED_LOCALES, isSupportedLocale, normalizeLocale, resolveSupportedLocale, + type LocaleRolloutStage, + type SupportedLocaleDefinition, type SupportedLocale, } from './locales'; +export { + LOCALIZATION_FOUNDATION, + LOCALIZED_SETTINGS_GENERAL_JOURNEY_KEYS, + NEVER_TRANSLATE_COPY_RULES, + SETTINGS_GENERAL_ALLOWED_IDENTICAL_TRANSLATIONS, + SETTINGS_GENERAL_NON_TRANSLATABLE_TOKENS, +} from './policy'; diff --git a/frontend-modern/src/i18n/locales.ts b/frontend-modern/src/i18n/locales.ts index 86d190e6e..475427922 100644 --- a/frontend-modern/src/i18n/locales.ts +++ b/frontend-modern/src/i18n/locales.ts @@ -4,6 +4,36 @@ export const SUPPORTED_LOCALES = ['en', 'de', 'es'] as const; export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +export type LocaleRolloutStage = 'source' | 'first-wave'; + +export interface SupportedLocaleDefinition { + label: string; + englishLabel: string; + fallbackLocale: typeof DEFAULT_LOCALE; + rolloutStage: LocaleRolloutStage; +} + +export const SUPPORTED_LOCALE_REGISTRY: Record = { + en: { + label: 'English', + englishLabel: 'English', + fallbackLocale: DEFAULT_LOCALE, + rolloutStage: 'source', + }, + de: { + label: 'Deutsch', + englishLabel: 'German', + fallbackLocale: DEFAULT_LOCALE, + rolloutStage: 'first-wave', + }, + es: { + label: 'Español', + englishLabel: 'Spanish', + fallbackLocale: DEFAULT_LOCALE, + rolloutStage: 'first-wave', + }, +}; + export const FIRST_LOCALIZATION_LOCALES = [ 'de', 'es', @@ -12,9 +42,9 @@ export const FIRST_LOCALIZATION_LOCALES = [ export const NEXT_LOCALIZATION_LOCALES = ['fr', 'pt-BR', 'ja', 'zh-Hans', 'ko'] as const; export const SUPPORTED_LOCALE_LABELS: Record = { - en: 'English', - de: 'Deutsch', - es: 'Español', + en: SUPPORTED_LOCALE_REGISTRY.en.label, + de: SUPPORTED_LOCALE_REGISTRY.de.label, + es: SUPPORTED_LOCALE_REGISTRY.es.label, }; const SUPPORTED_LOCALE_SET = new Set(SUPPORTED_LOCALES); @@ -40,7 +70,7 @@ export function isSupportedLocale(value: string): value is SupportedLocale { } export function resolveSupportedLocale(value: string | null | undefined): SupportedLocale | null { - const normalized = value?.trim().replace('_', '-').toLowerCase(); + const normalized = value?.trim().replace(/_/g, '-').toLowerCase(); if (!normalized) return null; if (isSupportedLocale(normalized)) return normalized; if (LOCALE_ALIASES[normalized]) return LOCALE_ALIASES[normalized]; @@ -52,3 +82,11 @@ export function resolveSupportedLocale(value: string | null | undefined): Suppor export function normalizeLocale(value: string | null | undefined): SupportedLocale { return resolveSupportedLocale(value) ?? DEFAULT_LOCALE; } + +export function getLocaleFallbackChain( + value: string | null | undefined, +): readonly SupportedLocale[] { + const locale = normalizeLocale(value); + const fallbackLocale = SUPPORTED_LOCALE_REGISTRY[locale].fallbackLocale; + return locale === fallbackLocale ? [locale] : [locale, fallbackLocale]; +} diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 9a69f0cd4..2befe095c 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -5,12 +5,52 @@ export const EN_MESSAGES = { 'settings.general.fullWidth.description': 'Expand content to use all available screen width on large monitors.', 'settings.general.fullWidth.title': 'Full-width mode', + 'settings.general.docker.envHint': 'Can also be set via environment variable:', + 'settings.general.docker.section.description': + 'Control how {sourceLabel} update actions appear across Pulse.', + 'settings.general.docker.section.title': '{sourceLabel} updates', + 'settings.general.docker.toggle.description': + 'When enabled, {sourceLabel} "Update" actions are hidden across Pulse. Update detection still runs, so available updates remain visible.', + 'settings.general.docker.toggle.title': 'Hide update buttons', 'settings.general.language.ariaLabel': 'App language', 'settings.general.language.description': 'Use this language for the app shell. Commands, resource names, and API fields stay unchanged.', 'settings.general.language.title': 'Language', + 'settings.general.monitoringCadence.current': 'Current cadence: {seconds} seconds ({duration})', + 'settings.general.monitoringCadence.custom.description': + 'Enter seconds ({min}-{max}). Applies to all clusters.', + 'settings.general.monitoringCadence.custom.title': 'Custom polling interval', + 'settings.general.monitoringCadence.description': + 'Shorter intervals provide near-real-time updates at the cost of higher API and CPU usage on each node. Set a longer interval to reduce load on busy clusters.', + 'settings.general.monitoringCadence.duration.minute': '{count} minute', + 'settings.general.monitoringCadence.duration.minutes': '{count} minutes', + 'settings.general.monitoringCadence.duration.underMinute': 'under a minute', + 'settings.general.monitoringCadence.envLocked': 'Managed via environment variable {envVar}.', + 'settings.general.monitoringCadence.preset.balanced': 'Balanced ({duration})', + 'settings.general.monitoringCadence.preset.custom': 'Custom', + 'settings.general.monitoringCadence.preset.low': 'Low ({duration})', + 'settings.general.monitoringCadence.preset.realtime': 'Realtime ({duration})', + 'settings.general.monitoringCadence.preset.veryLow': 'Very low ({duration})', + 'settings.general.monitoringCadence.section.description': + 'Control how frequently Pulse polls Proxmox VE nodes.', + 'settings.general.monitoringCadence.section.title': 'Monitoring cadence', 'settings.general.temperature.description': 'Display temperatures in Celsius or Fahrenheit.', 'settings.general.temperature.title': 'Temperature unit', + 'settings.general.telemetry.copyJson': 'Copy JSON', + 'settings.general.telemetry.description': + 'Help improve Pulse by sharing anonymous outbound usage data: a rotating install ID, normalized release identity, runtime platform, aggregate self-hosted adoption counts, and coarse feature flags. No hostnames, credentials, infrastructure identifiers, prompts, chat messages, or personal information are sent. Telemetry rows are retained for up to 90 days, and IP addresses are not stored in telemetry rows.', + 'settings.general.telemetry.disabledPreview': + 'Telemetry is currently disabled. This preview shows the payload Pulse would send if you enable it.', + 'settings.general.telemetry.fullDetails': 'Full details', + 'settings.general.telemetry.payloadAriaLabel': 'Telemetry payload preview', + 'settings.general.telemetry.payloadTitle': 'Current heartbeat payload', + 'settings.general.telemetry.previewPayload': 'Preview payload', + 'settings.general.telemetry.refreshPayload': 'Refresh payload', + 'settings.general.telemetry.resetId': 'Reset ID', + 'settings.general.telemetry.section.description': + 'Control anonymous outbound telemetry from this Pulse instance.', + 'settings.general.telemetry.section.title': 'Usage data and privacy', + 'settings.general.telemetry.title': 'Anonymous outbound telemetry', 'settings.general.theme.description': 'Choose light, dark, or sync with your system theme.', 'settings.general.theme.option.dark': 'Dark', 'settings.general.theme.option.light': 'Light', @@ -144,7 +184,62 @@ export const EN_MESSAGES = { export type I18nMessageKey = keyof typeof EN_MESSAGES; -export const I18N_MESSAGES: Record> = { +export type I18nCatalog = Record; +export type I18nCatalogs = Record; + +export const SETTINGS_GENERAL_MIGRATED_MESSAGE_KEYS = [ + 'settings.header.systemGeneral.description', + 'settings.header.systemGeneral.title', + 'settings.nav.group.system', + 'settings.nav.item.general', + 'settings.general.appearance.title', + 'settings.general.docker.envHint', + 'settings.general.docker.section.description', + 'settings.general.docker.section.title', + 'settings.general.docker.toggle.description', + 'settings.general.docker.toggle.title', + 'settings.general.fullWidth.description', + 'settings.general.fullWidth.title', + 'settings.general.language.ariaLabel', + 'settings.general.language.description', + 'settings.general.language.title', + 'settings.general.monitoringCadence.current', + 'settings.general.monitoringCadence.custom.description', + 'settings.general.monitoringCadence.custom.title', + 'settings.general.monitoringCadence.description', + 'settings.general.monitoringCadence.duration.minute', + 'settings.general.monitoringCadence.duration.minutes', + 'settings.general.monitoringCadence.duration.underMinute', + 'settings.general.monitoringCadence.envLocked', + 'settings.general.monitoringCadence.preset.balanced', + 'settings.general.monitoringCadence.preset.custom', + 'settings.general.monitoringCadence.preset.low', + 'settings.general.monitoringCadence.preset.realtime', + 'settings.general.monitoringCadence.preset.veryLow', + 'settings.general.monitoringCadence.section.description', + 'settings.general.monitoringCadence.section.title', + 'settings.general.telemetry.copyJson', + 'settings.general.telemetry.description', + 'settings.general.telemetry.disabledPreview', + 'settings.general.telemetry.fullDetails', + 'settings.general.telemetry.payloadAriaLabel', + 'settings.general.telemetry.payloadTitle', + 'settings.general.telemetry.previewPayload', + 'settings.general.telemetry.refreshPayload', + 'settings.general.telemetry.resetId', + 'settings.general.telemetry.section.description', + 'settings.general.telemetry.section.title', + 'settings.general.telemetry.title', + 'settings.general.temperature.description', + 'settings.general.temperature.title', + 'settings.general.theme.description', + 'settings.general.theme.option.dark', + 'settings.general.theme.option.light', + 'settings.general.theme.option.system', + 'settings.general.theme.title', +] as const satisfies readonly I18nMessageKey[]; + +export const I18N_MESSAGES: I18nCatalogs = { en: EN_MESSAGES, de: { ...EN_MESSAGES, @@ -152,12 +247,53 @@ export const I18N_MESSAGES: Record>; diff --git a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts index f0566949d..3f79be4f7 100644 --- a/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/systemSettingsPresentation.test.ts @@ -8,11 +8,16 @@ import { DOCKER_UPDATE_ACTIONS_SECTION_TITLE, DOCKER_UPDATE_ACTIONS_TOGGLE_DESCRIPTION, DOCKER_UPDATE_ACTIONS_TOGGLE_LABEL, + formatPvePollingDuration, getBackupIntervalSelectValue, getBackupIntervalSummary, getCheckForUpdatesErrorMessage, + getDockerUpdateActionsPresentation, getDockerUpdateActionsUpdateErrorMessage, getHideLocalLoginUpdateErrorMessage, + getPvePollingCadenceSummary, + getPvePollingCustomOption, + getPvePollingPresetOptions, getStartUpdateErrorMessage, getSystemSettingsSaveErrorMessage, getTelemetryUpdateErrorMessage, @@ -69,6 +74,33 @@ describe('systemSettingsPresentation', () => { ); }); + it('localizes migrated general settings polling copy without changing machine units', () => { + expect(getPvePollingPresetOptions('de')).toEqual([ + { label: 'Echtzeit (10s)', value: 10 }, + { label: 'Ausgewogen (30s)', value: 30 }, + { label: 'Niedrig (60s)', value: 60 }, + { label: 'Sehr niedrig (5m)', value: 300 }, + ]); + expect(getPvePollingCustomOption('es')).toEqual({ + label: 'Personalizado', + value: 'custom', + }); + expect(formatPvePollingDuration(90, 'de')).toBe('1,5 Minuten'); + expect(getPvePollingCadenceSummary(30, 'es')).toBe( + 'Cadencia actual: 30 segundos (menos de un minuto)', + ); + }); + + it('localizes Docker update-action copy while preserving product and command tokens', () => { + expect(getDockerUpdateActionsPresentation('de')).toMatchObject({ + sectionTitle: 'Docker / Podman-Updates', + toggleLabel: 'Update-Schaltflaechen ausblenden', + }); + expect(getDockerUpdateActionsPresentation('es').toggleDescription).toContain( + 'acciones "Update" de Docker / Podman', + ); + }); + it('returns canonical backup interval select values', () => { expect(getBackupIntervalSelectValue(false, 0)).toBe('0'); expect(getBackupIntervalSelectValue(false, 300)).toBe('300'); @@ -91,9 +123,7 @@ describe('systemSettingsPresentation', () => { it('returns canonical system settings operational failure copy', () => { expect(getSystemSettingsSaveErrorMessage()).toBe('Unable to save settings.'); expect(getSystemSettingsSaveErrorMessage('forbidden')).toBe('forbidden'); - expect(getHideLocalLoginUpdateErrorMessage()).toBe( - 'Unable to update local login visibility.', - ); + expect(getHideLocalLoginUpdateErrorMessage()).toBe('Unable to update local login visibility.'); expect(getDockerUpdateActionsUpdateErrorMessage()).toBe( 'Unable to update Docker / Podman update actions.', ); diff --git a/frontend-modern/src/utils/systemSettingsPresentation.ts b/frontend-modern/src/utils/systemSettingsPresentation.ts index 690747cc8..a2cbbd4b8 100644 --- a/frontend-modern/src/utils/systemSettingsPresentation.ts +++ b/frontend-modern/src/utils/systemSettingsPresentation.ts @@ -1,4 +1,11 @@ import { getSourcePlatformLabel } from '@/utils/sourcePlatforms'; +import { + getActiveLocale, + normalizeLocale, + t, + type I18nMessageKey, + type SupportedLocale, +} from '@/i18n'; export const PVE_POLLING_MIN_SECONDS = 10; export const PVE_POLLING_MAX_SECONDS = 3600; @@ -38,6 +45,85 @@ export const DOCKER_UPDATE_ACTIONS_SECTION_DESCRIPTION = `Control how ${DOCKER_P export const DOCKER_UPDATE_ACTIONS_TOGGLE_LABEL = 'Hide update buttons'; export const DOCKER_UPDATE_ACTIONS_TOGGLE_DESCRIPTION = `When enabled, ${DOCKER_PODMAN_SOURCE_LABEL} "Update" actions are hidden across Pulse. Update detection still runs, so available updates remain visible.`; +const PVE_POLLING_PRESET_LABEL_KEYS = { + 10: 'settings.general.monitoringCadence.preset.realtime', + 30: 'settings.general.monitoringCadence.preset.balanced', + 60: 'settings.general.monitoringCadence.preset.low', + 300: 'settings.general.monitoringCadence.preset.veryLow', +} as const satisfies Record<(typeof PVE_POLLING_PRESETS)[number]['value'], I18nMessageKey>; + +const PVE_POLLING_PRESET_DURATIONS = { + 10: '10s', + 30: '30s', + 60: '60s', + 300: '5m', +} as const satisfies Record<(typeof PVE_POLLING_PRESETS)[number]['value'], string>; + +function resolvePresentationLocale(locale?: SupportedLocale): SupportedLocale { + return locale ?? getActiveLocale(); +} + +export function getPvePollingPresetOptions(locale?: SupportedLocale) { + const presentationLocale = resolvePresentationLocale(locale); + return PVE_POLLING_PRESETS.map((option) => ({ + value: option.value, + label: t( + PVE_POLLING_PRESET_LABEL_KEYS[option.value], + { duration: PVE_POLLING_PRESET_DURATIONS[option.value] }, + presentationLocale, + ), + })); +} + +export function getPvePollingCustomOption(locale?: SupportedLocale) { + return { + value: 'custom' as const, + label: t('settings.general.monitoringCadence.preset.custom', {}, locale), + }; +} + +export function formatPvePollingDuration(seconds: number, locale?: SupportedLocale): string { + const presentationLocale = normalizeLocale(resolvePresentationLocale(locale)); + if (seconds < 60) { + return t('settings.general.monitoringCadence.duration.underMinute', {}, presentationLocale); + } + + const minutes = seconds / 60; + const count = new Intl.NumberFormat(presentationLocale, { + maximumFractionDigits: minutes % 1 === 0 ? 0 : 1, + }).format(minutes); + + return t( + minutes === 1 + ? 'settings.general.monitoringCadence.duration.minute' + : 'settings.general.monitoringCadence.duration.minutes', + { count }, + presentationLocale, + ); +} + +export function getPvePollingCadenceSummary(seconds: number, locale?: SupportedLocale): string { + const presentationLocale = resolvePresentationLocale(locale); + return t( + 'settings.general.monitoringCadence.current', + { seconds, duration: formatPvePollingDuration(seconds, presentationLocale) }, + presentationLocale, + ); +} + +export function getDockerUpdateActionsPresentation( + locale?: SupportedLocale, + sourceLabel = DOCKER_PODMAN_SOURCE_LABEL, +) { + return { + sectionTitle: t('settings.general.docker.section.title', { sourceLabel }, locale), + sectionDescription: t('settings.general.docker.section.description', { sourceLabel }, locale), + toggleLabel: t('settings.general.docker.toggle.title', {}, locale), + toggleDescription: t('settings.general.docker.toggle.description', { sourceLabel }, locale), + environmentHint: t('settings.general.docker.envHint', {}, locale), + } as const; +} + export function getBackupIntervalSelectValue( backupPollingUseCustom: boolean, backupPollingInterval: number,