mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Start localization foundation for settings journey
This commit is contained in:
+30
@@ -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.
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DockerRuntimeSettingsCardProps
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-base-content">
|
||||
{DOCKER_UPDATE_ACTIONS_SECTION_TITLE}
|
||||
{getDockerUpdateActionsPresentation().sectionTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-muted">{DOCKER_UPDATE_ACTIONS_SECTION_DESCRIPTION}</p>
|
||||
<p class="text-sm text-muted">{getDockerUpdateActionsPresentation().sectionDescription}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4 rounded-md border border-border bg-surface-hover p-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="docker-update-actions-toggle-label" class="text-sm font-medium text-base-content">
|
||||
{DOCKER_UPDATE_ACTIONS_TOGGLE_LABEL}
|
||||
<span
|
||||
id="docker-update-actions-toggle-label"
|
||||
class="text-sm font-medium text-base-content"
|
||||
>
|
||||
{getDockerUpdateActionsPresentation().toggleLabel}
|
||||
</span>
|
||||
<Show when={props.disableDockerUpdateActionsLocked()}>
|
||||
<EnvironmentLockBadge
|
||||
@@ -55,10 +55,10 @@ export const DockerRuntimeSettingsCard: Component<DockerRuntimeSettingsCardProps
|
||||
</Show>
|
||||
</div>
|
||||
<p id="docker-update-actions-toggle-description" class="text-xs text-muted">
|
||||
{DOCKER_UPDATE_ACTIONS_TOGGLE_DESCRIPTION}
|
||||
{getDockerUpdateActionsPresentation().toggleDescription}
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Can also be set via environment variable:{' '}
|
||||
{getDockerUpdateActionsPresentation().environmentHint}{' '}
|
||||
<code class="px-1 py-0.5 rounded bg-surface-hover text-base-content">
|
||||
{DOCKER_UPDATE_ACTIONS_ENV_VAR}=true
|
||||
</code>
|
||||
|
||||
@@ -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<number | 'custom'>[] = [
|
||||
...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<number | 'custom'>[] => [
|
||||
...getPvePollingPresetOptions(),
|
||||
getPvePollingCustomOption(),
|
||||
];
|
||||
|
||||
export interface GeneralSettingsPanelProps {
|
||||
@@ -214,8 +216,8 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
|
||||
{/* Usage Data + Privacy Card */}
|
||||
<SettingsPanel
|
||||
title="Usage data and privacy"
|
||||
description="Control anonymous outbound telemetry from this Pulse instance."
|
||||
title={t('settings.general.telemetry.section.title')}
|
||||
description={t('settings.general.telemetry.section.description')}
|
||||
noPadding
|
||||
bodyClass="divide-y divide-border"
|
||||
>
|
||||
@@ -224,21 +226,16 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content truncate">
|
||||
Anonymous outbound telemetry
|
||||
{t('settings.general.telemetry.title')}
|
||||
</span>
|
||||
<Show when={props.telemetryEnabledLocked()}>
|
||||
<EnvironmentLockBadge envVar="PULSE_TELEMETRY" />
|
||||
<EnvironmentLockBadge envVar={TELEMETRY_ENV_VAR} />
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
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')}{' '}
|
||||
<ExternalTextLink href={PRIVACY_DOC_URL} variant="muted">
|
||||
Full details
|
||||
{t('settings.general.telemetry.fullDetails')}
|
||||
</ExternalTextLink>
|
||||
</p>
|
||||
</div>
|
||||
@@ -257,7 +254,9 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (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')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -265,7 +264,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
disabled={props.resettingTelemetryInstallID()}
|
||||
onClick={() => void props.handleResetTelemetryInstallID()}
|
||||
>
|
||||
Reset ID
|
||||
{t('settings.general.telemetry.resetId')}
|
||||
</Button>
|
||||
<Show when={props.telemetryPreviewPayload()}>
|
||||
<Button
|
||||
@@ -273,7 +272,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
size="settingsActionXs"
|
||||
onClick={() => void props.handleCopyTelemetryPreview()}
|
||||
>
|
||||
Copy JSON
|
||||
{t('settings.general.telemetry.copyJson')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -282,17 +281,16 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<div class="rounded-md border border-border bg-surface-alt">
|
||||
<div class="flex flex-col gap-1 border-b border-border px-4 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Current heartbeat payload
|
||||
{t('settings.general.telemetry.payloadTitle')}
|
||||
</p>
|
||||
<Show when={!props.telemetryPreviewEnabled()}>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Telemetry is currently disabled. This preview shows the payload Pulse would send
|
||||
if you enable it.
|
||||
{t('settings.general.telemetry.disabledPreview')}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<pre
|
||||
aria-label="Telemetry payload preview"
|
||||
aria-label={t('settings.general.telemetry.payloadAriaLabel')}
|
||||
class="overflow-x-auto px-4 py-3 text-xs leading-relaxed text-base-content"
|
||||
>
|
||||
{props.telemetryPreviewPayload()}
|
||||
@@ -311,8 +309,8 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
|
||||
{/* Monitoring Cadence Card */}
|
||||
<SettingsPanel
|
||||
title="Monitoring cadence"
|
||||
description="Control how frequently Pulse polls Proxmox VE nodes."
|
||||
title={t('settings.general.monitoringCadence.section.title')}
|
||||
description={t('settings.general.monitoringCadence.section.description')}
|
||||
noPadding
|
||||
bodyClass="divide-y divide-border"
|
||||
>
|
||||
@@ -320,17 +318,10 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
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())}
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed max-w-3xl">
|
||||
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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -338,7 +329,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
{/* Preset buttons */}
|
||||
<FilterButtonGroup
|
||||
class="sm:grid-cols-2 xl:grid-cols-5"
|
||||
options={PVE_POLLING_OPTIONS}
|
||||
options={getPvePollingOptions()}
|
||||
value={props.pvePollingSelection()}
|
||||
onChange={handlePVEPollingSelection}
|
||||
variant="prominent"
|
||||
@@ -350,11 +341,13 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center gap-4 rounded-md border border-dashed border-border bg-surface-hover p-4 transition-all animate-in fade-in slide-in-from-top-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="block text-sm font-medium text-base-content truncate">
|
||||
Custom polling interval
|
||||
{t('settings.general.monitoringCadence.custom.title')}
|
||||
</label>
|
||||
<p class="text-xs text-muted mt-0.5 line-clamp-2">
|
||||
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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -397,7 +390,9 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
<circle cx="12" cy="16" r="0.5" />
|
||||
</svg>
|
||||
<span class="leading-relaxed">
|
||||
Managed via environment variable <strong>PVE_POLLING_INTERVAL</strong>.
|
||||
{t('settings.general.monitoringCadence.envLocked', {
|
||||
envVar: PVE_POLLING_INTERVAL_ENV_VAR,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
+37
-7
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+53
-1
@@ -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<GeneralSettingsPanelProps> = {}) {
|
||||
const [themePreference, setThemePreference] = createSignal<'light' | 'dark' | 'system'>('system');
|
||||
const [pvePollingInterval, setPVEPollingInterval] = createSignal(30);
|
||||
const [pvePollingSelection, setPVEPollingSelection] = createSignal<number | 'custom'>(30);
|
||||
@@ -42,6 +42,7 @@ function renderGeneralSettingsPanel() {
|
||||
disableDockerUpdateActionsLocked: () => false,
|
||||
savingDockerUpdateActions: () => false,
|
||||
handleDisableDockerUpdateActionsChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const result = render(() => <GeneralSettingsPanel {...props} />);
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<I18nMessageKey> = 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | undefined>(
|
||||
(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';
|
||||
|
||||
@@ -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<SupportedLocale, SupportedLocaleDefinition> = {
|
||||
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<SupportedLocale, string> = {
|
||||
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<string>(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];
|
||||
}
|
||||
|
||||
@@ -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<SupportedLocale, Record<I18nMessageKey, string>> = {
|
||||
export type I18nCatalog = Record<I18nMessageKey, string>;
|
||||
export type I18nCatalogs = Record<SupportedLocale, I18nCatalog>;
|
||||
|
||||
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<SupportedLocale, Record<I18nMessageKey, strin
|
||||
'settings.general.fullWidth.description':
|
||||
'Nutzen Sie auf grossen Monitoren die gesamte verfuegbare Bildschirmbreite.',
|
||||
'settings.general.fullWidth.title': 'Volle Breite',
|
||||
'settings.general.docker.envHint': 'Kann auch ueber Umgebungsvariable gesetzt werden:',
|
||||
'settings.general.docker.section.description':
|
||||
'Steuern Sie, wie {sourceLabel}-Updateaktionen in Pulse angezeigt werden.',
|
||||
'settings.general.docker.section.title': '{sourceLabel}-Updates',
|
||||
'settings.general.docker.toggle.description':
|
||||
'Wenn aktiviert, werden {sourceLabel}-Aktionen "Update" in Pulse ausgeblendet. Die Update-Erkennung laeuft weiter, sodass verfuegbare Updates sichtbar bleiben.',
|
||||
'settings.general.docker.toggle.title': 'Update-Schaltflaechen ausblenden',
|
||||
'settings.general.language.ariaLabel': 'App-Sprache',
|
||||
'settings.general.language.description':
|
||||
'Verwenden Sie diese Sprache fuer die App-Oberflaeche. Befehle, Ressourcennamen und API-Felder bleiben unveraendert.',
|
||||
'settings.general.language.title': 'Sprache',
|
||||
'settings.general.monitoringCadence.current': 'Aktueller Takt: {seconds} Sekunden ({duration})',
|
||||
'settings.general.monitoringCadence.custom.description':
|
||||
'Sekunden eingeben ({min}-{max}). Gilt fuer alle Cluster.',
|
||||
'settings.general.monitoringCadence.custom.title': 'Benutzerdefiniertes Abfrageintervall',
|
||||
'settings.general.monitoringCadence.description':
|
||||
'Kuerzere Intervalle liefern nahezu Echtzeit-Aktualisierungen auf Kosten hoeherer API- und CPU-Nutzung auf jedem Knoten. Waehlen Sie ein laengeres Intervall, um die Last auf ausgelasteten Clustern zu reduzieren.',
|
||||
'settings.general.monitoringCadence.duration.minute': '{count} Minute',
|
||||
'settings.general.monitoringCadence.duration.minutes': '{count} Minuten',
|
||||
'settings.general.monitoringCadence.duration.underMinute': 'unter einer Minute',
|
||||
'settings.general.monitoringCadence.envLocked':
|
||||
'Wird ueber Umgebungsvariable {envVar} verwaltet.',
|
||||
'settings.general.monitoringCadence.preset.balanced': 'Ausgewogen ({duration})',
|
||||
'settings.general.monitoringCadence.preset.custom': 'Benutzerdefiniert',
|
||||
'settings.general.monitoringCadence.preset.low': 'Niedrig ({duration})',
|
||||
'settings.general.monitoringCadence.preset.realtime': 'Echtzeit ({duration})',
|
||||
'settings.general.monitoringCadence.preset.veryLow': 'Sehr niedrig ({duration})',
|
||||
'settings.general.monitoringCadence.section.description':
|
||||
'Steuern Sie, wie oft Pulse Proxmox VE-Knoten abfragt.',
|
||||
'settings.general.monitoringCadence.section.title': 'Monitoring-Takt',
|
||||
'settings.general.temperature.description': 'Temperaturen in Celsius oder Fahrenheit anzeigen.',
|
||||
'settings.general.temperature.title': 'Temperatureinheit',
|
||||
'settings.general.telemetry.copyJson': 'JSON kopieren',
|
||||
'settings.general.telemetry.description':
|
||||
'Helfen Sie, Pulse zu verbessern, indem Sie anonyme ausgehende Nutzungsdaten teilen: eine rotierende Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, aggregierte Self-hosted-Nutzungszahlen und grobe Funktionsflags. Es werden keine Hostnamen, Zugangsdaten, Infrastrukturkennungen, Prompts, Chatnachrichten oder personenbezogenen Daten gesendet. Telemetriezeilen werden bis zu 90 Tage aufbewahrt, und IP-Adressen werden nicht in Telemetriezeilen gespeichert.',
|
||||
'settings.general.telemetry.disabledPreview':
|
||||
'Telemetrie ist derzeit deaktiviert. Diese Vorschau zeigt den Payload, den Pulse senden wuerde, wenn Sie sie aktivieren.',
|
||||
'settings.general.telemetry.fullDetails': 'Details',
|
||||
'settings.general.telemetry.payloadAriaLabel': 'Telemetrie-Payload-Vorschau',
|
||||
'settings.general.telemetry.payloadTitle': 'Aktueller Heartbeat-Payload',
|
||||
'settings.general.telemetry.previewPayload': 'Payload anzeigen',
|
||||
'settings.general.telemetry.refreshPayload': 'Payload aktualisieren',
|
||||
'settings.general.telemetry.resetId': 'ID zuruecksetzen',
|
||||
'settings.general.telemetry.section.description':
|
||||
'Steuern Sie anonyme ausgehende Telemetrie von dieser Pulse-Instanz.',
|
||||
'settings.general.telemetry.section.title': 'Nutzungsdaten und Datenschutz',
|
||||
'settings.general.telemetry.title': 'Anonyme ausgehende Telemetrie',
|
||||
'settings.general.theme.description': 'Waehlen Sie hell, dunkel oder die Systemeinstellung.',
|
||||
'settings.general.theme.option.dark': 'Dunkel',
|
||||
'settings.general.theme.option.light': 'Hell',
|
||||
@@ -296,12 +432,54 @@ export const I18N_MESSAGES: Record<SupportedLocale, Record<I18nMessageKey, strin
|
||||
'settings.general.fullWidth.description':
|
||||
'Expande el contenido para usar todo el ancho disponible en monitores grandes.',
|
||||
'settings.general.fullWidth.title': 'Modo de ancho completo',
|
||||
'settings.general.docker.envHint': 'También se puede definir mediante variable de entorno:',
|
||||
'settings.general.docker.section.description':
|
||||
'Controla cómo aparecen las acciones de actualización de {sourceLabel} en Pulse.',
|
||||
'settings.general.docker.section.title': 'Actualizaciones de {sourceLabel}',
|
||||
'settings.general.docker.toggle.description':
|
||||
'Cuando está activado, las acciones "Update" de {sourceLabel} se ocultan en Pulse. La detección de actualizaciones sigue ejecutándose, por lo que las actualizaciones disponibles siguen visibles.',
|
||||
'settings.general.docker.toggle.title': 'Ocultar botones de actualización',
|
||||
'settings.general.language.ariaLabel': 'Idioma de la app',
|
||||
'settings.general.language.description':
|
||||
'Usa este idioma para la interfaz de la app. Los comandos, nombres de recursos y campos API no cambian.',
|
||||
'settings.general.language.title': 'Idioma',
|
||||
'settings.general.monitoringCadence.current':
|
||||
'Cadencia actual: {seconds} segundos ({duration})',
|
||||
'settings.general.monitoringCadence.custom.description':
|
||||
'Introduce segundos ({min}-{max}). Se aplica a todos los clusters.',
|
||||
'settings.general.monitoringCadence.custom.title': 'Intervalo de sondeo personalizado',
|
||||
'settings.general.monitoringCadence.description':
|
||||
'Los intervalos más cortos ofrecen actualizaciones casi en tiempo real a costa de mayor uso de API y CPU en cada nodo. Usa un intervalo más largo para reducir la carga en clusters ocupados.',
|
||||
'settings.general.monitoringCadence.duration.minute': '{count} minuto',
|
||||
'settings.general.monitoringCadence.duration.minutes': '{count} minutos',
|
||||
'settings.general.monitoringCadence.duration.underMinute': 'menos de un minuto',
|
||||
'settings.general.monitoringCadence.envLocked':
|
||||
'Administrado mediante variable de entorno {envVar}.',
|
||||
'settings.general.monitoringCadence.preset.balanced': 'Equilibrado ({duration})',
|
||||
'settings.general.monitoringCadence.preset.custom': 'Personalizado',
|
||||
'settings.general.monitoringCadence.preset.low': 'Bajo ({duration})',
|
||||
'settings.general.monitoringCadence.preset.realtime': 'Tiempo real ({duration})',
|
||||
'settings.general.monitoringCadence.preset.veryLow': 'Muy bajo ({duration})',
|
||||
'settings.general.monitoringCadence.section.description':
|
||||
'Controla con qué frecuencia Pulse sondea nodos de Proxmox VE.',
|
||||
'settings.general.monitoringCadence.section.title': 'Cadencia de supervisión',
|
||||
'settings.general.temperature.description': 'Muestra temperaturas en Celsius o Fahrenheit.',
|
||||
'settings.general.temperature.title': 'Unidad de temperatura',
|
||||
'settings.general.telemetry.copyJson': 'Copiar JSON',
|
||||
'settings.general.telemetry.description':
|
||||
'Ayuda a mejorar Pulse compartiendo datos de uso salientes anónimos: un ID de instalación rotativo, identidad de versión normalizada, plataforma de ejecución, conteos agregados de adopción autohospedada y flags de funciones de alto nivel. No se envían hostnames, credenciales, identificadores de infraestructura, prompts, mensajes de chat ni información personal. Las filas de telemetría se conservan hasta 90 días y las direcciones IP no se guardan en filas de telemetría.',
|
||||
'settings.general.telemetry.disabledPreview':
|
||||
'La telemetría está desactivada. Esta vista previa muestra el payload que Pulse enviaría si la activas.',
|
||||
'settings.general.telemetry.fullDetails': 'Detalles completos',
|
||||
'settings.general.telemetry.payloadAriaLabel': 'Vista previa del payload de telemetría',
|
||||
'settings.general.telemetry.payloadTitle': 'Payload de heartbeat actual',
|
||||
'settings.general.telemetry.previewPayload': 'Vista previa del payload',
|
||||
'settings.general.telemetry.refreshPayload': 'Actualizar payload',
|
||||
'settings.general.telemetry.resetId': 'Restablecer ID',
|
||||
'settings.general.telemetry.section.description':
|
||||
'Controla telemetría saliente anónima de esta instancia de Pulse.',
|
||||
'settings.general.telemetry.section.title': 'Datos de uso y privacidad',
|
||||
'settings.general.telemetry.title': 'Telemetría saliente anónima',
|
||||
'settings.general.theme.description':
|
||||
'Elige claro, oscuro o sincroniza con el tema del sistema.',
|
||||
'settings.general.theme.option.dark': 'Oscuro',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
FIRST_LOCALIZATION_LOCALES,
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from './locales';
|
||||
import { SETTINGS_GENERAL_MIGRATED_MESSAGE_KEYS, type I18nMessageKey } from './messages';
|
||||
|
||||
export const LOCALIZATION_FOUNDATION = {
|
||||
ownerLayer: 'frontend-modern/src/i18n',
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
supportedLocales: SUPPORTED_LOCALES,
|
||||
firstWaveLocales: FIRST_LOCALIZATION_LOCALES,
|
||||
fallbackBehavior:
|
||||
'Normalize locale input to a supported locale, then fall back to English for unsupported locales and missing catalog entries.',
|
||||
catalogShape:
|
||||
'English is the source catalog. Every supported locale catalog must expose the same message keys. Migrated first-wave surfaces must provide explicit German and Spanish strings instead of inheriting English copy.',
|
||||
} as const;
|
||||
|
||||
export const NEVER_TRANSLATE_COPY_RULES = [
|
||||
'API response fields, request fields, generated type names, and persistence keys stay machine-stable.',
|
||||
'Configuration keys, environment variable names, CLI commands, shell snippets, and code samples stay byte-stable.',
|
||||
'Resource names, hostnames, user-entered labels, node names, IDs, and log/event payloads stay as reported by the source system.',
|
||||
'Product and integration identifiers stay unchanged unless product governance explicitly approves a localized brand form.',
|
||||
] as const;
|
||||
|
||||
export const SETTINGS_GENERAL_NON_TRANSLATABLE_TOKENS = [
|
||||
'Pulse',
|
||||
'Proxmox VE',
|
||||
'Docker / Podman',
|
||||
'API',
|
||||
'CPU',
|
||||
'IP',
|
||||
'JSON',
|
||||
'PULSE_TELEMETRY',
|
||||
'PVE_POLLING_INTERVAL',
|
||||
'PULSE_DISABLE_DOCKER_UPDATE_ACTIONS',
|
||||
'"Update"',
|
||||
] as const;
|
||||
|
||||
export const LOCALIZED_SETTINGS_GENERAL_JOURNEY_KEYS =
|
||||
SETTINGS_GENERAL_MIGRATED_MESSAGE_KEYS satisfies readonly I18nMessageKey[];
|
||||
|
||||
export const SETTINGS_GENERAL_ALLOWED_IDENTICAL_TRANSLATIONS = {
|
||||
de: ['settings.nav.group.system', 'settings.general.theme.option.system'],
|
||||
es: ['settings.header.systemGeneral.title', 'settings.nav.item.general'],
|
||||
} as const satisfies Partial<Record<SupportedLocale, readonly I18nMessageKey[]>>;
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user