Make telemetry consent a setup choice and retire the payload banner

The setup wizard's telemetry card only ever offered a way out: it led
with "enabled by default", gave no reason the data exists, and told the
reader to set PULSE_TELEMETRY=false before starting a process that had
already sent its first ping two minutes after boot. The payload-update
banner paired "we now collect more" with a one-click Disable button, was
keyed to schema v2 from July and never re-triggered across fifteen later
bumps, and its text was rewritten in August so anyone who had dismissed
it never saw the new wording. Nothing on either surface said what the
data is for or what it is never used for. No GitHub issue or discussion
has ever complained about the default-on posture, so the defensive
framing was answering a question nobody asked while quietly nudging
people to opt out.

Setup now leads with what the daily summary is for (development effort
follows real use; the features and platforms the operator relies on get
priority), names concrete exclusions (hostnames, credentials, IP
addresses), states what it is never used for (not sold or shared, not
used for advertising, not linked to a Pulse account or license), and
puts a real Usage statistics toggle on the admin-account step. The
toggle defaults to on and, when switched off, is applied through the
canonical system-settings endpoint once the admin token exists, so there
is no setup-only side channel and the account is created either way.
Neither setup screen tells the reader how to turn it off; the switch is
the control. The env-var instruction moves to PRIVACY.md where a reader
can still act on it, alongside a note that the first ping fires about
two minutes after start.

The payload-update banner is retired along with its telemetryAction deep
link that changed the preference on arrival. Payload changes are now
disclosed in a dated changelog in PRIVACY.md (back-filled from schema v2
to v17 from the telemetry package's own version notes) and in release
notes; an in-app notice is reserved for a change in kind. PRIVACY.md
gains a "What it is not used for" section whose statements are facts
about the license-server path, which never joins telemetry rows to
license or customer records; the contract treats any change to that path
as a change in kind. Settings leads with what the data is for and makes
Preview payload the primary action, because the exact runtime payload is
the disclosure an operator can verify. The security-privacy,
deployment-installability, and frontend-primitives contracts record the
new rules. Telemetry and i18n proof tests pin the setup choice, the
purpose-first and never-sold wording in every locale, and the changelog
row for the current schema so a future bump cannot land undisclosed.

Demand ledger: repos/pulse-pro FEATURE_REQUESTS.md "Telemetry consent as
a real setup choice" (named bet, pulse-pro PR #40). Supersedes the
three-commit branch behind Pulse PR #1873, rebuilt on current main.
This commit is contained in:
Richard Courtman
2026-09-02 19:04:54 +01:00
parent ef460aa654
commit a70c96c8a3
23 changed files with 353 additions and 425 deletions
+36
View File
@@ -22,9 +22,12 @@ While mock/demo fixture mode is enabled, Pulse suppresses outbound telemetry ent
#### How to disable
- During first-run setup, switch off **Usage statistics** on the admin-account step, or
- **Settings → System → General → Outbound usage telemetry** (toggle off), or
- Set the environment variable `PULSE_TELEMETRY=false`
The first startup ping is sent about two minutes after Pulse starts. The setup and Settings switches stop every later ping; setting `PULSE_TELEMETRY=false` before the first start prevents the first one as well.
#### How to inspect or rotate it
- **Settings → System → General → Preview payload** shows the exact heartbeat JSON Pulse would send with the current runtime state.
@@ -346,6 +349,16 @@ added.
- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values
- No names, email addresses, account identifiers, or other intentionally identifying personal content
#### What it is not used for
- It is not sold, licensed, or shared with anyone else. Pulse's maintainer is the only reader, and the only destination is Pulse's own license server.
- It is not used for advertising, marketing, or outreach of any kind. Nothing in it can address you.
- It is not linked to a Pulse account, license key, purchase, or email address. The license server never joins telemetry rows to those records.
- It is not used to single out an install. Reads are aggregate, and the install ID rotates every 30 days.
- It is not kept: rows are deleted after 90 days.
If any of this ever changes, that is a change in kind under **Payload changes** above and comes with an in-app notice before it takes effect.
#### Install ID rotation
The telemetry install ID is pseudonymous, is not tied to a Pulse account, and rotates automatically every 30 days.
@@ -359,6 +372,29 @@ highest activation stage reached. This local state contains no user, account,
resource, URL, or content identifiers. It exists so daily pings can report
buckets instead of exporting a sequence of setup events.
#### Payload changes
Every change to the payload bumps the schema version, is listed here with its date, and appears in the release notes of the first release that carries it. Pulse does not interrupt existing installations with an in-app notice for a new counter inside an already-disclosed category; **Preview payload** in Settings always shows the exact current contract. An in-app notice is reserved for a change in kind: a new identifier, a new class of data, or a change to retention or handling.
| Schema | Date | Change |
|--------|------|--------|
| 17 | 2026-09-02 | Closed Patrol provider class, effective Patrol autonomy level, coarse 30-day Patrol token buckets, and per-outcome investigation counts |
| 16 | 2026-08-30 | Four content-free workload-history adoption counters, each counted at most once per browser session |
| 15 | 2026-08-29 | Notification destination HTTP 5xx failures separated from rejected HTTP 4xx responses |
| 14 | 2026-08-29 | Identity-free alert quality outcomes in closed severity, age, and resolution-time buckets, with tenant denominators |
| 13 | 2026-08-29 | Local UI/API service observation plus the immediately previous release observation |
| 12 | 2026-08-27 | Patrol-origin action funnel counters |
| 11 | 2026-08-24 | Node connection test attempt and failure counts |
| 10 | 2026-08-21 | Patrol runtime blocked cause, from a fixed category list |
| 9 | 2026-08-19 | Refusals with no machine reason code separated from refusals with an unrecognised code |
| 8 | 2026-08-13 | Agent-side pre-mutation refusals split into target-change, prerequisite, and invalid-contract categories |
| 7 | 2026-08-05 | `audit_reads_30d` replaces `audit_logging_persistent` and `audit_events_30d` |
| 6 | 2026-08-05 | Licensed-feature adoption counts; the never-populated Patrol autofix counter removed |
| 5 | 2026-07-29 | Bounded, content-free notification failure classes |
| 4 | 2026-07-27 | Complete approved-action outcome accounting, fixed pre-dispatch refusal categories, and verified finding-resolution linkage |
| 3 | 2026-07-23 | `notification_failures_7d` becomes a terminal-delivery count |
| 2 | 2026-07-23 | Coarse deployment, lifecycle, and estate-size buckets plus aggregate alert and notification outcome signals |
#### Source code
The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted.
@@ -1445,12 +1445,10 @@ artifact-selection behaviour.
later installed release. Automatic release communication is limited to a
compact non-blocking update notice; the detailed changelog may open only
after explicit operator action. Preparing that notice records the version
immediately so a reload cannot turn it into a recurring prompt. When the
one-time telemetry disclosure owns the same session, it suppresses the
lower-priority release notice instead of creating consecutive notices.
immediately so a reload cannot turn it into a recurring prompt.
`frontend-modern/src/utils/localStorage.ts` owns that browser-session notice
reservation boundary so the release notice, telemetry disclosure, and
GitHub gratitude prompt cannot create a one-two sequence. The post-update
reservation boundary so the release notice and the GitHub gratitude prompt
cannot create a one-two sequence. The post-update
surface must not reuse the Highlights summary as its content,
and must stay silent for a first baseline, malformed or development
versions, missing releases, and releases without categorized changes.
@@ -1458,14 +1456,14 @@ artifact-selection behaviour.
keeps it to at most three short plain-text bullets of no more than 140
characters each, with links, code, issue references, and nested structure
reserved for the categorized or full release notes.
The same post-update communication boundary owns the one-time schema-v2
telemetry payload notice. It must use a non-blocking shared notice banner,
appear only for existing installations on a published build, stay silent
for fresh installs and development/source builds, persist acknowledgement,
and provide direct payload-preview, disable, and privacy-disclosure actions.
The corresponding next-release disclosure must enumerate the added coarse
signal categories and exclusions without inventing a release version before
the packet is cut.
The post-update communication boundary does not announce telemetry payload
changes. Those are disclosed in the dated `Payload changes` section of
`docs/PRIVACY.md` and in the release notes of the first release that carries
them, so `frontend-modern/src/components/WhatsNewCard.tsx` renders only the
release notice and the notice reservation recognizes only the release notice
and the GitHub gratitude prompt as owners. A retired payload-update banner
paired a disclosure with a one-click disable action; `security-privacy` now
owns when an in-app telemetry notice is warranted and forbids that pairing.
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
First-run browser helpers are part of that dev-runtime proof boundary. They
must preserve the setup-created API token in the shared runtime state, prefer
@@ -927,7 +927,15 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
may be named, while hostnames, credentials, infrastructure identifiers,
URLs, paths, locale, browser events, prompts, chat messages, command text,
action output, token values, and personal information must stay explicitly
excluded.
excluded. `Preview payload` is the panel's primary action, because the exact
runtime payload is the disclosure an operator can verify; the enable toggle
and `Reset ID` stay secondary controls, and the summary copy opens with what
the data is for before enumerating categories and exclusions. The shared
settings shell no longer accepts a `telemetryAction` deep link that changes
the preference on arrival; the preference changes only from the panel.
The summary copy also states what the data is never used for (sold,
shared, advertising, account or license linkage) in every locale, with the
`security-privacy` disclosure as the source of those statements.
8. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: 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 `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary.
These settings panels consume the privileged security-status projection,
@@ -1612,12 +1612,31 @@ That same telemetry trust boundary must remain operator-inspectable in-product:
the shared system settings surface may preview only the exact runtime payload
Pulse would send, and it must allow an operator to rotate the local telemetry
install ID immediately without waiting for the scheduled 30-day window.
An existing installation's first published schema-v2 upgrade must also receive
a one-time, non-blocking notice that names the coarse payload expansion and
links directly to the exact preview, the disable action, and the governed
privacy disclosure. Fresh installs stay silent because setup already presents
the current disclosure. Acknowledging the notice may persist locally, but it
must not change the operator's telemetry preference by itself.
Payload changes are disclosed through the dated `Payload changes` section of
that same governed privacy disclosure and through the release notes of the
first release that carries them, and every change must bump the schema
version. Existing installations are not interrupted with an in-app notice for
a new counter inside an already-disclosed category; the Settings payload
preview is the live disclosure. An in-app notice is reserved for a change in
kind (a new identifier, a new class of data, or a change to retention or
handling), and such a notice must not carry a one-click disable action: a
disable control attached to a disclosure reads as a prompt to opt out rather
than as information. First-run setup must present the telemetry choice as a
real control on the first authenticated step, defaulting to enabled and
applied through the canonical system-settings path once the admin token
exists; it must not instruct the operator to set an environment variable
before a process that has already started. That instruction belongs in the
privacy disclosure and install docs, where the reader can still act on it.
Setup, disclosure, and Settings copy must say what the data is for before
saying how to turn it off, and must name concrete exclusions (hostnames,
credentials, IP addresses) rather than only abstract categories.
The same copy, and the governed privacy disclosure's `What it is not used
for` section, must also state the negative uses plainly: not sold or shared,
not used for advertising or outreach, not linked to a Pulse account, license,
purchase, or email address, and not used to single out an install. Those are
statements of fact about the license-server path (which never joins telemetry
rows to license or customer records), so any change to that path is a change
in kind that requires the in-app notice above before it takes effect.
That same governed privacy disclosure must also state the current server-side
telemetry retention and handling rules plainly. If the license-server path
retains telemetry rows for a fixed window or uses client IPs transiently for
+36 -108
View File
@@ -1,115 +1,37 @@
{
"version": 1,
"base_sha": "3016bc72a0e64c43a1c4e49d805b59874e875643",
"verified_at": "2026-09-02T12:15:21Z",
"base_sha": "ef460aa6542a41728e2fe3e4a8266488c919b0be",
"verified_at": "2026-09-02T18:04:36Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/SearchInput.tsx",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx",
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx"
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/Settings.tsx",
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx",
"frontend-modern/src/components/WhatsNewCard.tsx",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/utils/localStorage.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/SearchInput.tsx": "ba88362103f5479034a79a9fb888e87187d39876209e624421088bfb1bced26a",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "5ea264b2f1bcdd5d2561646e9e19d704fcb948fbcebd34f7ec7a2c91b564c626",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "4abd9a32b9540dbf049e0a7a9ebc914c33519525ee5ddf43cf50c4713767a7b1",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "64d9092d9b0537a42352b21ce038865251a80d1a23ee90d4defced0f50e4dbd3",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "addda2de56331ada4002c81a73e2005a9b5cb336d7575a912216bdf794a2667c",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "fb8a45ef41eaa926b18a1a2d0f5e5586730431cd9b2ab7db79dbf57f343e3a6f",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "e824f88e2b09c3afe843d7ddc236c26ee1218a5bf1506a8aad256d2550e6cd97",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "c3fdbc2976a84967d0682e078e5f5f8943f5658b194e6b4a30b9c7208cf56f8f",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "b936d55463a05b50ca60e6dccc2471dc142f02863fe077833a299aabfff308b3",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx": "25c821633dd0d82223968a0d29a196e5afc7da8105f35eb897488a40bfba965d",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx": "9b702a777e0572b6014b32fe64773bac469c84140f925eecdc86911495da0222",
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "d8b52db2416a11ac76ede1407c3ab7521f7633a160052049a6033bd253890608",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "41793497dcef26c328de418807bdab3e42b583631452ed164edc89a5dbd41add",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "59339fbd307b7e77386df84db34fe59cbb47bd8f8e9ea478afeab90f8ae9e094",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "5f521812a318b54e5d3db1ed81b3e778c59cedd93e81ed535c9d3e5f4c9df1a7",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx": "1f50a7ff4ddc2acbb4162ccecddc08bfbd6b39d7d8f32bf6bf68a63b5864a20b",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "61a8629ec241659a178760b6e97f4a137204ddbc9f52f4e4baecb6b719df527d",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "171a22ff20c69edd59be65bf4dcf2af1e2765fa4156e511e1689b0c9eab73db5",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "e98b2480e091269dfa342d7bbef6976663f626e6bd4335d38a85a40edb3feb26",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "1fa633f5dfb149d9a8f08182b08632fa6fdcc38ba91cd20c6ab5c17cd9244e36",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "318d20f8b896ae94f9c34ce521b9f105bfeebc8c2b1d06489930e055f4986710",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "cbbee7fcad2e6c17c405b70c69c8dd476e36a6dcdaa937a9372446336bcee277",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "a94c17b87e120be4051e70c20e7a63716309e94adc6c7b48550ab8857be169cf",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "eaefd6a7c9ef5e4e43c29afc9c7de1a1fec513a02bf17f110f1c3d31962054b5",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "1cd2c8fd0e5edc4db005a8d3f9fa0a49732b2aac3d829288e1e6c2ec5b8c9bdd",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "ff8de65244d1f36ccb9e628be2c51404c6c3aea3a5f57905a98cd218754fcb76",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "fe9669116eb81930f93b6a978ecfd7b9a9fa75d96d02843f3f2fe02256bf2902",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "97db101cb72d4813e75442da69398397a8b2d36501bfe13b7826820499aaf540",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "f52833e1b5fb978b560925eaaa2a70583e0e43e400c3de58962b6c423aed143b",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "a66016ab4fac48e7f1d5fa01c320cfbcb5ee062477d8cc93e22e42277a023fc3",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "0ee88b539ef6fbd73844c09be2a23e6eb032b6098f557c7d50c7d052148c0577",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "fe75fa3c4764b9d878a53594919e2dc352d2761a46d0c291205c48ebedf8877e",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "e68c504226a0f63b07d84c2200f18770a1afd3eac064289fdbfb68365c838757",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "2c8750d9dbf0e70c6b39aff9b4c8f2797b1b738150a0c2741bd3cad854a6864c",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "bff8eddc760ff714cffc366ec0c4f7fb36a3d16250b11373d1dc05e724ea8ffd",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "4e81749f64327e2f0ce383f5d4f5065cdbb707ad4b719284e6d85c3e95d74c45",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "323f06f58b0f24aac4fb5fc17b46c464262dcb313923f22d38ba25822cf34bbe",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "e94c62f1bc8b944fb5937e0035d5db4940a4d3995faeb826d9f9cc46bc891ade",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "766fdb139db983be5c3630dbc361459eaebfc74c6e71ef4be4bce8516b5a084c",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "3763d567a22b5a5c3f3516529be7ed1ccf51108a8c19537a291de0c44f8471e0",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "eab67145c573d37fa1e91385b7deda97c3e4a97aa14e1eaf58aa56ea940f60a4",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "c40be5911f961215ed4ceb3e4838d6efa52684d1bda550068c2502bdfdb838ec",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "48117aa58a96ea3b0c1c46e624621b6dced88bf368cc9affcd675884037f65bf",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "2f613f2ae6ad4a299cec849d237f22af20bf1dd2a41bf6859dce70af464c1b5f",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "8d77ce4fb029efde83e71006703f0b6c74db18295ce0dfdeee1931a2c780f0b2",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "de897d462823484d2167785cafbf0e4f029a243fdc98f6ff00082e1b458adf3f",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "63891a9f1f8f798bfc91a0c917da207b8a077c362483d1ebe07d3709afeeeeaf",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "b667f8183ccb5a763d9af82b6b6dfc4b5a8db109cfdf4069e3383ca2d588a028"
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "9512529c0e6b86ba4e1055c5009158d442bd8d4a84c2dd9f73822a053989886a",
"frontend-modern/src/components/Settings/Settings.tsx": "72682c86dd055761cbd68d4f57b684afe4d978a0b53748859d0721e711ce321a",
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx": "7f8ddc0c826b5abe90f40ac08ef3856d046510a7f72afe2f40f32ae72e547eba",
"frontend-modern/src/components/WhatsNewCard.tsx": "90543f19d61e9ba6d93251a7bcc9c179fa8d7ccb6ab3364e364d0ea13b2c31f0",
"frontend-modern/src/i18n/messages.de.ts": "f43d3c32d59f32178531e11a56e6014d384831871bb8cc3b472fca8e20a96840",
"frontend-modern/src/i18n/messages.es.ts": "928c7c7b34dd75ffd00639cb8b27ad81c0e6c8210db8ff41c234a26c5b9c9031",
"frontend-modern/src/i18n/messages.ts": "fef88e4096b8d49dc8e4a5b721804bb3b602dddef442827f0b315bec0d45a435",
"frontend-modern/src/utils/localStorage.ts": "19a532f55271b2fc696e3ba4ae6ba098e759f0f9df207015c803f7fcc0050bd6"
},
"routes": [
"/truenas/overview"
"/ (first-run setup wizard, fresh data dir)",
"/settings/system-general#usage-telemetry (mock data, existing-install localStorage baseline)",
"/docs/PRIVACY (shipped privacy document)"
],
"viewports": [
{
"width": 1280,
"height": 800
"height": 900
},
{
"width": 390,
@@ -117,15 +39,21 @@
}
],
"states": [
"Populated TrueNAS Systems table at desktop and narrow widths with reduced motion",
"Collapsed static resource row without tabindex or disclosure aria, with one named native disclosure button",
"Expanded inline system detail controlled by the disclosure button after keyboard and pointer activation",
"Platform inline-completion search exposed as a native textbox without unsupported combobox popup semantics"
"Setup welcome step: Usage statistics card, purpose-first copy, never-sold statement, \"you choose on the next step\", no PULSE_TELEMETRY instruction",
"Setup security step (bootstrap validation and quick-setup responses intercepted by Playwright, no real token): Usage statistics toggle default on, description leads with the benefit and carries the never-sold statement",
"Setup security step: toggle off, aria-pressed false; completion reached after Create Account",
"Settings system-general with prior release baseline: no telemetry-payload-update-notice banner rendered",
"Settings Usage data and privacy panel: description leads with purpose, carries the never-sold statement, Preview payload rendered as primary Button, Reset ID secondary",
"Settings panel with payload preview open at 390px: no horizontal page overflow",
"Shipped /docs/PRIVACY renders the \"What it is not used for\" section and the Payload changes table",
"Locales: en verified live; de/es catalog copy covered by i18n and SetupWizard localization unit tests"
],
"interactions": [
"focused the named disclosure button and expanded it with Enter at desktop and narrow widths",
"collapsed the disclosure with Space and confirmed aria-expanded and aria-controls stayed on the button",
"expanded the same detail by clicking non-interactive row content while retaining whole-row pointer convenience",
"confirmed the controlled detail row remained visible and ran the axe WCAG A/AA scan in the expanded state"
"Fill bootstrap token field with a placeholder and click Verify bootstrap token (validation endpoint intercepted)",
"Click Usage statistics toggle on the security step",
"Click Create Account & Continue: observed POST /api/security/quick-setup then POST /api/system/settings/update {\"telemetryEnabled\":false}",
"Click Preview payload in Settings and read the rendered heartbeat JSON",
"Click Full details from the Settings panel and read the popup document",
"Resize to 390x844 and repeat the wizard, security step, and Settings panel checks"
]
}
+36
View File
@@ -22,9 +22,12 @@ While mock/demo fixture mode is enabled, Pulse suppresses outbound telemetry ent
#### How to disable
- During first-run setup, switch off **Usage statistics** on the admin-account step, or
- **Settings → System → General → Outbound usage telemetry** (toggle off), or
- Set the environment variable `PULSE_TELEMETRY=false`
The first startup ping is sent about two minutes after Pulse starts. The setup and Settings switches stop every later ping; setting `PULSE_TELEMETRY=false` before the first start prevents the first one as well.
#### How to inspect or rotate it
- **Settings → System → General → Preview payload** shows the exact heartbeat JSON Pulse would send with the current runtime state.
@@ -346,6 +349,16 @@ added.
- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values
- No names, email addresses, account identifiers, or other intentionally identifying personal content
#### What it is not used for
- It is not sold, licensed, or shared with anyone else. Pulse's maintainer is the only reader, and the only destination is Pulse's own license server.
- It is not used for advertising, marketing, or outreach of any kind. Nothing in it can address you.
- It is not linked to a Pulse account, license key, purchase, or email address. The license server never joins telemetry rows to those records.
- It is not used to single out an install. Reads are aggregate, and the install ID rotates every 30 days.
- It is not kept: rows are deleted after 90 days.
If any of this ever changes, that is a change in kind under **Payload changes** above and comes with an in-app notice before it takes effect.
#### Install ID rotation
The telemetry install ID is pseudonymous, is not tied to a Pulse account, and rotates automatically every 30 days.
@@ -359,6 +372,29 @@ highest activation stage reached. This local state contains no user, account,
resource, URL, or content identifiers. It exists so daily pings can report
buckets instead of exporting a sequence of setup events.
#### Payload changes
Every change to the payload bumps the schema version, is listed here with its date, and appears in the release notes of the first release that carries it. Pulse does not interrupt existing installations with an in-app notice for a new counter inside an already-disclosed category; **Preview payload** in Settings always shows the exact current contract. An in-app notice is reserved for a change in kind: a new identifier, a new class of data, or a change to retention or handling.
| Schema | Date | Change |
|--------|------|--------|
| 17 | 2026-09-02 | Closed Patrol provider class, effective Patrol autonomy level, coarse 30-day Patrol token buckets, and per-outcome investigation counts |
| 16 | 2026-08-30 | Four content-free workload-history adoption counters, each counted at most once per browser session |
| 15 | 2026-08-29 | Notification destination HTTP 5xx failures separated from rejected HTTP 4xx responses |
| 14 | 2026-08-29 | Identity-free alert quality outcomes in closed severity, age, and resolution-time buckets, with tenant denominators |
| 13 | 2026-08-29 | Local UI/API service observation plus the immediately previous release observation |
| 12 | 2026-08-27 | Patrol-origin action funnel counters |
| 11 | 2026-08-24 | Node connection test attempt and failure counts |
| 10 | 2026-08-21 | Patrol runtime blocked cause, from a fixed category list |
| 9 | 2026-08-19 | Refusals with no machine reason code separated from refusals with an unrecognised code |
| 8 | 2026-08-13 | Agent-side pre-mutation refusals split into target-change, prerequisite, and invalid-contract categories |
| 7 | 2026-08-05 | `audit_reads_30d` replaces `audit_logging_persistent` and `audit_events_30d` |
| 6 | 2026-08-05 | Licensed-feature adoption counts; the never-populated Patrol autofix counter removed |
| 5 | 2026-07-29 | Bounded, content-free notification failure classes |
| 4 | 2026-07-27 | Complete approved-action outcome accounting, fixed pre-dispatch refusal categories, and verified finding-resolution linkage |
| 3 | 2026-07-23 | `notification_failures_7d` becomes a terminal-delivery count |
| 2 | 2026-07-23 | Coarse deployment, lifecycle, and estate-size buckets plus aggregate alert and notification outcome signals |
#### Source code
The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted.
@@ -285,7 +285,7 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<div class="flex flex-wrap gap-2 sm:gap-3">
<Button
variant="secondary"
variant="primary"
size="settingsActionXs"
disabled={props.loadingTelemetryPreview()}
onClick={() => void props.handleLoadTelemetryPreview()}
@@ -167,41 +167,6 @@ const SettingsWorkspace: Component<SettingsProps> = (props) => {
}
return settingsPanelRegistry()[currentTab];
});
let handledTelemetryActionHref = '';
createEffect(() => {
if (activeTab() !== 'system-general' || !infrastructureSettings.initialLoadComplete()) {
return;
}
const action = new URLSearchParams(location.search).get('telemetryAction');
if (action !== 'preview' && action !== 'disable') {
return;
}
const actionHref = `${location.pathname}${location.search}${location.hash}`;
if (actionHref === handledTelemetryActionHref) {
return;
}
handledTelemetryActionHref = actionHref;
queueMicrotask(() => {
document.getElementById('usage-telemetry')?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
if (action === 'preview') {
void systemSettings.handleLoadTelemetryPreview();
} else {
void systemSettings.handleTelemetryEnabledChange(false);
}
navigate('/settings/system-general#usage-telemetry', {
replace: true,
scroll: false,
});
});
});
createEffect(() => {
activeTab();
queueMicrotask(() => window.scrollTo({ top: 0, behavior: 'auto' }));
@@ -934,10 +934,12 @@ describe('settings architecture guardrails', () => {
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.payloadAriaLabel');
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.resetId');
expect(generalSettingsPanelSource).toContain('id="usage-telemetry"');
expect(settingsSource).toContain("get('telemetryAction')");
expect(settingsSource).toContain('systemSettings.handleLoadTelemetryPreview()');
expect(settingsSource).toContain('systemSettings.handleTelemetryEnabledChange(false)');
expect(settingsSource).toContain("navigate('/settings/system-general#usage-telemetry'");
// The payload-update banner and its one-click disable deep link were
// retired: payload changes are disclosed in release notes and the dated
// PRIVACY.md changelog, and the preference is changed only from the panel.
expect(settingsSource).not.toContain('telemetryAction');
expect(settingsSource).not.toContain('handleTelemetryEnabledChange(false)');
expect(generalSettingsPanelSource).toContain('variant="primary"');
expect(generalSettingsPanelSource).not.toContain('license_tier');
expect(generalSettingsPanelSource).not.toContain('api_tokens');
});
@@ -115,5 +115,65 @@ describe('SecurityStep', () => {
expect(storedHandoff.createdAt).toEqual(expect.any(String));
expect(onComplete).toHaveBeenCalledOnce();
expect(showErrorMock).not.toHaveBeenCalled();
// Usage statistics stay on by default, so setup makes no settings write.
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
});
it('offers the usage statistics choice and applies an opt-out after the account exists', async () => {
const onComplete = vi.fn();
render(() => (
<SecurityStep
state={baseState}
updateState={vi.fn()}
bootstrapToken="bootstrap-token"
onComplete={onComplete}
onBack={vi.fn()}
/>
));
const toggle = screen.getByRole('button', { name: 'Usage statistics' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(/never hostnames, credentials, or IP addresses/)).toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(screen.getByRole('button', { name: /Create Account & Continue/i }));
await waitFor(() => expect(onComplete).toHaveBeenCalledOnce());
expect(apiFetchJSONMock).toHaveBeenCalledTimes(2);
expect(apiFetchJSONMock.mock.calls[0][0]).toBe('/api/security/quick-setup');
const [settingsUrl, settingsInit] = apiFetchJSONMock.mock.calls[1] as [string, RequestInit];
expect(settingsUrl).toBe('/api/system/settings/update');
expect(settingsInit.method).toBe('POST');
expect(JSON.parse(String(settingsInit.body))).toEqual({ telemetryEnabled: false });
expect(showErrorMock).not.toHaveBeenCalled();
});
it('keeps the account when the opt-out write fails and says so', async () => {
const onComplete = vi.fn();
apiFetchJSONMock
.mockResolvedValueOnce({ success: true })
.mockRejectedValueOnce(new Error('settings unavailable'));
render(() => (
<SecurityStep
state={baseState}
updateState={vi.fn()}
bootstrapToken="bootstrap-token"
onComplete={onComplete}
onBack={vi.fn()}
/>
));
fireEvent.click(screen.getByRole('button', { name: 'Usage statistics' }));
fireEvent.click(screen.getByRole('button', { name: /Create Account & Continue/i }));
await waitFor(() => expect(onComplete).toHaveBeenCalledOnce());
expect(showErrorMock).toHaveBeenCalledWith(
'Your admin account was created, but usage statistics could not be turned off. You can turn them off in Settings → System → General.',
);
});
});
@@ -72,7 +72,7 @@ describe('localized setup wizard journey', () => {
expect(screen.getByText('Bienvenido a Pulse')).toBeInTheDocument();
expect(screen.getByText('Desbloquear configuración')).toBeInTheDocument();
expect(screen.getByText(/Conecta una API de plataforma/)).toBeInTheDocument();
expect(screen.getByText('La telemetría de uso está activada por defecto')).toBeInTheDocument();
expect(screen.getByText('Estadísticas de uso')).toBeInTheDocument();
expect(screen.getByText('sudo pulse bootstrap-token')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Verificar token de bootstrap →' }),
@@ -71,10 +71,11 @@ describe('WelcomeStep', () => {
'Connect a platform API, install Pulse Agent, or use both for full coverage.',
),
).toBeInTheDocument();
expect(screen.getByText('Usage telemetry is enabled by default')).toBeInTheDocument();
expect(screen.getByText('Usage statistics')).toBeInTheDocument();
expect(
screen.getByText(/To disable it before any ping, set PULSE_TELEMETRY=false/),
screen.getByText(/You choose on the next step and can change it at any time in Settings/),
).toBeInTheDocument();
expect(screen.queryByText(/PULSE_TELEMETRY=false/)).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Full details' })).toHaveAttribute(
'href',
'/docs/PRIVACY',
@@ -3,6 +3,8 @@ import { t } from '@/i18n';
import { showError } from '@/utils/toast';
import { setApiToken as setApiClientToken, apiFetchJSON } from '@/utils/apiClient';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { SettingsAPI } from '@/api/settings';
import { Toggle } from '@/components/shared/Toggle';
import type { WizardState } from '../SetupWizard';
interface SecurityStepProps {
@@ -23,6 +25,11 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
const [confirmPassword, setConfirmPassword] = createSignal('');
const [showPassword, setShowPassword] = createSignal(false);
const [isSettingUp, setIsSettingUp] = createSignal(false);
// Outbound usage telemetry stays on by default; this is the first
// authenticated moment where the operator can decide, so the choice lives
// here instead of as an environment-variable instruction that arrives after
// the process has already started.
const [shareUsageStatistics, setShareUsageStatistics] = createSignal(true);
const generatePassword = () => {
const password: string[] = [];
@@ -110,6 +117,17 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
}
}
if (!shareUsageStatistics()) {
// The admin token is already active, so the preference is written
// through the canonical system-settings path rather than a setup-only
// side channel. The account exists either way; only report the miss.
try {
await SettingsAPI.updateSystemSettings({ telemetryEnabled: false });
} catch {
showError(t('setup.security.error.telemetryDisableFailed'));
}
}
props.onComplete();
} catch (error) {
showError(t('setup.security.error.setupFailed', { error: String(error) }));
@@ -211,6 +229,15 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
</Show>
</div>
<div class="bg-base rounded-md p-4 border border-border text-left">
<Toggle
checked={shareUsageStatistics()}
onChange={() => setShareUsageStatistics(!shareUsageStatistics())}
label={t('setup.security.telemetry.title')}
description={t('setup.security.telemetry.description')}
/>
</div>
<div class="bg-base rounded-md p-4 border border-border text-left">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted mb-2">
{t('setup.security.nextScreen.title')}
+3 -116
View File
@@ -1,7 +1,5 @@
import { Show, createEffect, createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import InfoIcon from 'lucide-solid/icons/info';
import XIcon from 'lucide-solid/icons/x';
import { updateStore } from '@/stores/updates';
import { UpdatesAPI } from '@/api/updates';
@@ -13,13 +11,8 @@ import { InlineNotice } from '@/components/shared/InlineNotice';
import { buildReleaseNotesUrl, normalizeReleaseVersion } from '@/components/updateVersion';
import { extractChangelog, isReleaseVersion } from '@/components/whatsNewModel';
import { renderMarkdown } from '@/components/AI/aiChatUtils';
import { t } from '@/i18n';
import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
import { logger } from '@/utils/logger';
const TELEMETRY_PAYLOAD_NOTICE_VERSION = '2';
const TELEMETRY_SETTINGS_SECTION_ID = 'usage-telemetry';
const readLastSeenVersion = (): string | null => {
try {
return localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN);
@@ -36,25 +29,6 @@ const markVersionSeen = (version: string) => {
}
};
const readTelemetryPayloadNoticeVersion = (): string | null => {
try {
return localStorage.getItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN);
} catch {
return null;
}
};
const markTelemetryPayloadNoticeSeen = () => {
try {
localStorage.setItem(
STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN,
TELEMETRY_PAYLOAD_NOTICE_VERSION,
);
} catch {
// Private mode / storage disabled: setup and Settings remain the fallback disclosure.
}
};
/**
* Post-update "What's New" notice. A compact non-blocking notice appears once
* after the running version changes and only when that release has categorized
@@ -62,26 +36,16 @@ const markTelemetryPayloadNoticeSeen = () => {
* explicit action. Preparing the notice (or finding no categorized entries)
* records the version so reloads stay quiet until the next update.
*
* This release communication boundary also owns the one-time, non-blocking
* telemetry schema v2 notice. Existing installations see it once; fresh
* installs stay quiet because setup already presents the current disclosure.
* Telemetry payload changes are not announced here. They are disclosed in
* release notes and the dated "Payload changes" section of docs/PRIVACY.md;
* the Settings payload preview always shows the exact current contract.
*/
export function WhatsNewCard() {
const navigate = useNavigate();
const [noticeVisible, setNoticeVisible] = createSignal(false);
const [dialogVisible, setDialogVisible] = createSignal(false);
const [telemetryNoticeVisible, setTelemetryNoticeVisible] = createSignal(false);
const [version, setVersion] = createSignal('');
const [changelogHtml, setChangelogHtml] = createSignal('');
const hadPriorReleaseBaseline = readLastSeenVersion() !== null;
const telemetryNoticeAlreadySeen =
readTelemetryPayloadNoticeVersion() === TELEMETRY_PAYLOAD_NOTICE_VERSION;
const telemetryNoticeNeedsSession = hadPriorReleaseBaseline && !telemetryNoticeAlreadySeen;
if (telemetryNoticeNeedsSession) {
reserveLowPriorityNoticeSession('telemetry-update');
}
let checked = false;
let telemetryNoticeChecked = false;
const loadNotes = async (currentVersion: string, noticeSlotReserved: boolean) => {
try {
@@ -142,28 +106,6 @@ export function WhatsNewCard() {
void loadNotes(currentVersion, noticeSlotReserved);
});
createEffect(() => {
const info = updateStore.versionInfo();
if (!info || telemetryNoticeChecked) return;
telemetryNoticeChecked = true;
if (info.isDevelopment || info.isSourceBuild || !isReleaseVersion(info.version)) {
return;
}
if (!hadPriorReleaseBaseline) {
// Setup already showed the current telemetry disclosure on a fresh install.
markTelemetryPayloadNoticeSeen();
return;
}
if (readTelemetryPayloadNoticeVersion() === TELEMETRY_PAYLOAD_NOTICE_VERSION) {
return;
}
setTelemetryNoticeVisible(true);
});
const dismissNotice = () => {
setNoticeVisible(false);
};
@@ -177,63 +119,8 @@ export function WhatsNewCard() {
setDialogVisible(false);
};
const dismissTelemetryNotice = () => {
markTelemetryPayloadNoticeSeen();
setTelemetryNoticeVisible(false);
};
const openTelemetrySettings = (action: 'preview' | 'disable') => {
dismissTelemetryNotice();
navigate(`/settings/system-general?telemetryAction=${action}#${TELEMETRY_SETTINGS_SECTION_ID}`);
};
return (
<>
<Show when={telemetryNoticeVisible()}>
<InlineNotice
role="status"
data-testid="telemetry-payload-update-notice"
tone="info"
layout="banner"
icon={<InfoIcon class="h-4 w-4" aria-hidden="true" />}
onDismiss={dismissTelemetryNotice}
dismissLabel={t('settings.general.telemetry.notice.dismissLabel')}
dismissTitle={t('settings.general.telemetry.notice.dismissTitle')}
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="leading-relaxed">
<span class="font-semibold">{t('settings.general.telemetry.notice.title')}.</span>{' '}
{t('settings.general.telemetry.notice.description')}
</p>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="settingsActionXs"
onClick={() => openTelemetrySettings('preview')}
>
{t('settings.general.telemetry.notice.preview')}
</Button>
<Button
type="button"
variant="secondary"
size="settingsActionXs"
onClick={() => openTelemetrySettings('disable')}
>
{t('settings.general.telemetry.notice.disable')}
</Button>
<ExternalTextLink
href={PRIVACY_DOC_URL}
variant="compactInherit"
onClick={dismissTelemetryNotice}
>
{t('settings.general.telemetry.notice.privacy')}
</ExternalTextLink>
</div>
</div>
</InlineNotice>
</Show>
<Show when={noticeVisible()}>
<aside
class="fixed bottom-[var(--pulse-mobile-nav-height)] left-4 right-4 z-30 max-w-sm md:right-auto md:bottom-4"
@@ -4,11 +4,6 @@ import { STORAGE_KEYS } from '@/utils/localStorage';
const versionInfoMock = vi.hoisted(() => vi.fn());
const getReleaseNotesMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
vi.mock('@solidjs/router', () => ({
useNavigate: () => navigateMock,
}));
vi.mock('@/stores/updates', () => ({
updateStore: {
@@ -30,7 +25,6 @@ describe('WhatsNewCard', () => {
beforeEach(() => {
versionInfoMock.mockReset();
getReleaseNotesMock.mockReset();
navigateMock.mockReset();
localStorage.clear();
sessionStorage.clear();
});
@@ -52,13 +46,11 @@ describe('WhatsNewCard', () => {
await renderCard();
expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
expect(getReleaseNotesMock).not.toHaveBeenCalled();
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
expect(localStorage.getItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN)).toBe('2');
});
it('shows the telemetry payload update once to an existing installation', async () => {
it('never shows a telemetry payload notice to an existing installation', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.1.0-rc.1');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
@@ -68,68 +60,13 @@ describe('WhatsNewCard', () => {
await renderCard();
expect(screen.getByTestId('telemetry-payload-update-notice')).toBeInTheDocument();
expect(screen.getByText('Telemetry payload updated.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Disable telemetry' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Privacy details' })).toHaveAttribute(
'href',
'/docs/PRIVACY',
);
});
it('opens the exact payload preview and permanently dismisses the notice', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.1.0-rc.1');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
isSourceBuild: false,
});
await renderCard();
fireEvent.click(screen.getByRole('button', { name: 'Preview payload' }));
expect(navigateMock).toHaveBeenCalledWith(
'/settings/system-general?telemetryAction=preview#usage-telemetry',
);
expect(localStorage.getItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN)).toBe('2');
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
});
it('opens the disable action and permanently dismisses the notice', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.1.0-rc.1');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
isSourceBuild: false,
});
await renderCard();
fireEvent.click(screen.getByRole('button', { name: 'Disable telemetry' }));
expect(navigateMock).toHaveBeenCalledWith(
'/settings/system-general?telemetryAction=disable#usage-telemetry',
);
expect(localStorage.getItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN)).toBe('2');
});
it('does not show the telemetry notice after it has been acknowledged', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.1.0-rc.1');
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
isSourceBuild: false,
});
await renderCard();
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Disable telemetry' })).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('announces the running release without opening a blocking dialog', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
@@ -189,7 +126,6 @@ describe('WhatsNewCard', () => {
it('dismisses the compact notice without reopening it on reload', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
@@ -219,8 +155,9 @@ describe('WhatsNewCard', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('keeps the release notice quiet when the telemetry disclosure owns the session', async () => {
it('yields the notice slot to a higher-priority owner already holding the session', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
sessionStorage.setItem('pulse-low-priority-notice-owner', 'github-star');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
@@ -236,15 +173,9 @@ describe('WhatsNewCard', () => {
await renderCard();
await waitFor(() => {
expect(screen.getByTestId('telemetry-payload-update-notice')).toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
});
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss telemetry payload update' }));
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
});
it('stays quiet when a release has only a highlights summary', async () => {
@@ -280,9 +211,7 @@ describe('WhatsNewCard', () => {
await renderCard();
expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
expect(getReleaseNotesMock).not.toHaveBeenCalled();
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.0.5');
expect(localStorage.getItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN)).toBeNull();
});
});
@@ -246,13 +246,13 @@ describe('isReleaseVersion', () => {
});
describe('post-update telemetry disclosure', () => {
it('keeps the schema-v2 notice on the shared non-blocking release boundary', () => {
expect(whatsNewCardSource).toContain('TELEMETRY_PAYLOAD_NOTICE_VERSION');
expect(whatsNewCardSource).toContain('data-testid="telemetry-payload-update-notice"');
expect(whatsNewCardSource).toContain('layout="banner"');
expect(whatsNewCardSource).toContain("openTelemetrySettings('preview')");
expect(whatsNewCardSource).toContain("openTelemetrySettings('disable')");
expect(whatsNewCardSource).toContain('PRIVACY_DOC_URL');
it('does not announce telemetry payload changes from the release boundary', () => {
// Payload changes are disclosed in release notes and the dated
// PRIVACY.md changelog. A banner that pairs "we now collect more" with a
// one-click disable button reads as an opt-out prompt, so it was retired.
expect(whatsNewCardSource).not.toContain('TELEMETRY_PAYLOAD_NOTICE_VERSION');
expect(whatsNewCardSource).not.toContain('telemetry-payload-update-notice');
expect(whatsNewCardSource).not.toContain('openTelemetrySettings');
});
it('keeps release details opt-in and coordinates the automatic notice session', () => {
@@ -230,8 +230,6 @@ describe('i18n foundation', () => {
for (const locale of FIRST_LOCALIZATION_LOCALES) {
const telemetryDescription = I18N_MESSAGES[locale]['settings.general.telemetry.description'];
const telemetryUpdateNotice =
I18N_MESSAGES[locale]['settings.general.telemetry.notice.description'];
expect(I18N_MESSAGES[locale]['settings.general.language.description']).toContain('API');
expect(telemetryDescription).toContain('Pulse');
@@ -241,8 +239,6 @@ describe('i18n foundation', () => {
for (const term of telemetryPrivacyTerms[locale]) {
expect(telemetryDescription, `${locale}:${term}`).toContain(term);
}
expect(telemetryUpdateNotice).toContain('Pulse');
expect(telemetryUpdateNotice).not.toMatch(/anonymous/i);
expect(I18N_MESSAGES[locale]['settings.general.telemetry.copyJson']).toContain('JSON');
expect(
t(
@@ -295,9 +291,28 @@ describe('i18n foundation', () => {
const deploymentChoice = I18N_MESSAGES[locale]['setup.welcome.deploymentHint.choose'];
const genericTokenHelp = I18N_MESSAGES[locale]['setup.welcome.tokenHelp.generic'];
const setupTelemetryChoice = I18N_MESSAGES[locale]['setup.security.telemetry.description'];
expect(telemetryNotice).toContain('Pulse');
expect(telemetryNotice).toContain('PULSE_TELEMETRY=false');
expect(telemetryNotice).toContain('IP');
expect(telemetryNotice).not.toContain('PULSE_TELEMETRY');
expect(telemetryNotice).not.toMatch(/anonymous/i);
expect(setupTelemetryChoice).toContain('IP');
expect(setupTelemetryChoice).not.toMatch(/anonymous/i);
// Both setup surfaces say what the summary is for before what it holds,
// and neither tells the reader how to turn it off: the toggle is the
// control, and the how-to-disable copy lives in Full details and Settings.
const purposeWording = { de: 'Prioritaet', es: 'prioridad' } as const;
expect(setupTelemetryChoice).toContain(purposeWording[locale]);
expect(telemetryNotice).not.toMatch(/ausschalten|desactivarl/i);
// Every locale states what the summary is never used for, because
// "sold to someone" is the fear that turns a default-on switch off.
const neverSold = { de: 'verkauft', es: 'se vende' } as const;
expect(telemetryNotice).toContain(neverSold[locale]);
expect(setupTelemetryChoice).toContain(neverSold[locale]);
expect(I18N_MESSAGES[locale]['settings.general.telemetry.description']).toContain(
neverSold[locale],
);
expect(deploymentChoice).toContain('Pulse');
expect(deploymentChoice).not.toContain('Docker');
expect(deploymentChoice).not.toContain('LXC');
+10 -13
View File
@@ -272,6 +272,8 @@ export const DE_MESSAGE_OVERRIDES = {
'setup.security.error.passwordRequired': 'Bitte geben Sie ein Passwort ein',
'setup.security.error.passwordTooShort': 'Das Passwort muss mindestens 12 Zeichen haben',
'setup.security.error.setupFailed': 'Einrichtung fehlgeschlagen: {error}',
'setup.security.error.telemetryDisableFailed':
'Ihr Admin-Konto wurde erstellt, aber die Nutzungsstatistiken konnten nicht ausgeschaltet werden. Sie koennen sie unter Einstellungen → System → Allgemein ausschalten.',
'setup.security.generatedPasswordHelp':
'Ein sicheres 20-Zeichen-Passwort wird erzeugt und auf dem naechsten Bildschirm angezeigt.',
'setup.security.label.confirmPassword': 'Passwort bestaetigen',
@@ -288,10 +290,16 @@ export const DE_MESSAGE_OVERRIDES = {
'setup.security.placeholder.username': 'admin',
'setup.security.showPassword.hide': 'Ausblenden',
'setup.security.showPassword.show': 'Anzeigen',
'setup.security.telemetry.description':
'Eine kleine taegliche Nutzungszusammenfassung senden, damit die Funktionen und Plattformen, auf die Sie sich verlassen, Prioritaet bekommen. Sie enthaelt Zaehler und An/Aus-Flags mit einer rotierenden pseudonymen Installations-ID, niemals Hostnamen, Zugangsdaten oder IP-Adressen. Sie wird niemals verkauft oder weitergegeben und ist nicht mit einem Pulse-Konto verknuepft. Jederzeit in den Einstellungen aenderbar.',
'setup.security.telemetry.title': 'Nutzungsstatistiken',
'setup.security.title': 'Admin-Konto erstellen',
'setup.step.firstSource': 'Erste Quelle',
'setup.step.security': 'Sicherheit',
'setup.step.unlockServer': 'Server entsperren',
'setup.welcome.telemetryNotice.description':
'Pulse sendet eine kleine taegliche Nutzungszusammenfassung, die zeigt, welche Releases im Einsatz sind und welche Funktionen und Plattformen genutzt werden, damit die Entwicklung dort ansetzt, wo Pulse tatsaechlich genutzt wird. Sie enthaelt Zaehler und An/Aus-Flags mit einer rotierenden pseudonymen Installations-ID, niemals Hostnamen, Zugangsdaten, IP-Adressen oder etwas, das Sie eingeben. Sie wird niemals verkauft, weitergegeben oder fuer Werbung verwendet und ist nicht mit einem Pulse-Konto oder einer Lizenz verknuepft. Sie ist standardmaessig aktiviert. Sie entscheiden im naechsten Schritt und koennen es jederzeit in den Einstellungen aendern.',
'setup.welcome.telemetryNotice.title': 'Nutzungsstatistiken',
'setup.wizard.ariaLabel': 'Pulse-Einrichtungsassistent',
'setup.welcome.action.continueSecurity': 'Weiter zu Sicherheit',
'setup.welcome.action.verifyToken': 'Bootstrap-Token pruefen',
@@ -330,10 +338,7 @@ export const DE_MESSAGE_OVERRIDES = {
'setup.welcome.hero.title': 'Willkommen bei Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Bootstrap-Token einfuegen',
'setup.welcome.success.commandCopied': 'Befehl in Zwischenablage kopiert',
'setup.welcome.telemetryNotice.description':
'Ausgehende Nutzungstelemetrie ist standardmaessig aktiviert. Pulse sendet einen verzoegerten Start-Ping und einen taeglichen Heartbeat mit einer rotierenden pseudonymen Installations-ID, Release-/Runtime-Details, aggregierten Zaehlern und Funktionsflags. Um sie vor jedem Ping zu deaktivieren, setzen Sie PULSE_TELEMETRY=false, bevor Sie Pulse starten. Spaeter koennen Sie sie auch in den Einstellungen ausschalten.',
'setup.welcome.telemetryNotice.detailsLink': 'Details',
'setup.welcome.telemetryNotice.title': 'Nutzungstelemetrie ist standardmaessig aktiviert',
'setup.welcome.tokenHelp.afterVerify':
'Nachdem Pulse dieses Token geprueft hat, erstellen Sie im naechsten Schritt das Admin-Konto fuer diesen Server.',
'setup.welcome.tokenHelp.docker':
@@ -386,22 +391,14 @@ export const DE_MESSAGE_OVERRIDES = {
'settings.general.monitoringCadence.section.description':
'Steuern Sie, wie oft Pulse Proxmox VE-Knoten abfragt.',
'settings.general.monitoringCadence.section.title': 'Monitoring-Takt',
'settings.general.telemetry.description':
'Helfen Sie, Pulse zu verbessern, indem Sie eine taegliche Nutzungszusammenfassung teilen. Payload anzeigen zeigt genau, was gesendet wuerde: eine rotierende pseudonyme Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, grobe Kategorien fuer Bereitstellungsart und Lebenszyklus, aggregierte Ressourcen- und Ergebniszahlen, grobe Funktionsflags, inhaltsfreie Nutzungszaehler fuer Patrol, Assistant und Capability-APIs sowie vier sitzungsdeduplizierte Zaehler zur Nutzung des Workload-Verlaufs. Er enthaelt niemals Hostnamen, Zugangsdaten, Infrastrukturkennungen, URLs, Pfade, Gebietsschema, Browser-Ereignisse, Clickstream-Daten, Prompts, Chatnachrichten, Befehlstexte, Aktionsausgaben, Token-Werte, Namen, E-Mail-Adressen oder IP-Adressen. Er wird niemals verkauft, an andere weitergegeben oder fuer Werbung verwendet und ist nicht mit einem Pulse-Konto oder einer Lizenz verknuepft. Telemetriezeilen werden bis zu 90 Tage aufbewahrt, und Anfrage-IP-Adressen werden nur kurzzeitig fuer Rate-Limiting verwendet und nicht in Telemetriezeilen gespeichert.',
'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 ausgehende Nutzungsdaten teilen: eine rotierende pseudonyme Installations-ID, normalisierte Release-Identitaet, Laufzeitplattform, grobe Kategorien fuer Bereitstellungsart und Lebenszyklus, aggregierte Ressourcen- und Ergebniszahlen, grobe Funktionsflags sowie inhaltsfreie Nutzungszaehler fuer Patrol, Assistant und Capability-APIs. Der Payload enthaelt keine Hostnamen, Zugangsdaten, Infrastrukturkennungen, URLs, Pfade, Gebietsschema, Browser-Ereignisse, Prompts, Chatnachrichten, Befehlstexte, Aktionsausgaben, Token-Werte, Namen, E-Mail-Adressen oder IP-Adressen. Telemetriezeilen werden bis zu 90 Tage aufbewahrt, und Anfrage-IP-Adressen werden nur kurzzeitig fuer Rate-Limiting verwendet und 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.notice.description':
'Pulse fuegt jetzt grobe Signale zu Bereitstellung, Lebenszyklus, Bestandsgroesse sowie aggregierte Alarm- und Benachrichtigungsergebnisse hinzu, wenn ausgehende Nutzungstelemetrie aktiviert ist. Persoenliche Daten, Infrastruktur-IDs, Inhalte, Browser-Ereignisse und Clickstream-Daten bleiben ausgeschlossen.',
'settings.general.telemetry.notice.disable': 'Telemetrie deaktivieren',
'settings.general.telemetry.notice.dismissLabel': 'Hinweis zum Telemetrie-Payload schliessen',
'settings.general.telemetry.notice.dismissTitle': 'Dauerhaft schliessen',
'settings.general.telemetry.notice.preview': 'Payload anzeigen',
'settings.general.telemetry.notice.privacy': 'Datenschutzdetails',
'settings.general.telemetry.notice.title': 'Telemetrie-Payload aktualisiert',
'settings.general.telemetry.payloadAriaLabel': 'Telemetrie-Payload-Vorschau',
'settings.general.telemetry.payloadTitle': 'Aktueller Heartbeat-Payload',
'settings.general.telemetry.previewPayload': 'Payload anzeigen',
+10 -14
View File
@@ -265,6 +265,8 @@ export const ES_MESSAGE_OVERRIDES = {
'setup.security.error.passwordRequired': 'Ingresa una contraseña',
'setup.security.error.passwordTooShort': 'La contraseña debe tener al menos 12 caracteres',
'setup.security.error.setupFailed': 'La configuración falló: {error}',
'setup.security.error.telemetryDisableFailed':
'Tu cuenta de administrador se creó, pero las estadísticas de uso no se pudieron desactivar. Puedes desactivarlas en Ajustes → Sistema → General.',
'setup.security.generatedPasswordHelp':
'Se generará una contraseña segura de 20 caracteres y se mostrará en la siguiente pantalla.',
'setup.security.label.confirmPassword': 'Confirmar contraseña',
@@ -280,10 +282,16 @@ export const ES_MESSAGE_OVERRIDES = {
'setup.security.placeholder.username': 'admin',
'setup.security.showPassword.hide': 'Ocultar',
'setup.security.showPassword.show': 'Mostrar',
'setup.security.telemetry.description':
'Enviar un pequeño resumen diario de uso para que las funciones y plataformas de las que dependes tengan prioridad. Contiene conteos y flags de activado/desactivado con un ID de instalación seudónimo rotativo, nunca hostnames, credenciales ni direcciones IP. Nunca se vende ni se comparte, y no está vinculado a una cuenta de Pulse. Puedes cambiarlo en cualquier momento en Ajustes.',
'setup.security.telemetry.title': 'Estadísticas de uso',
'setup.security.title': 'Crear tu cuenta de administrador',
'setup.step.firstSource': 'Primera fuente',
'setup.step.security': 'Seguridad',
'setup.step.unlockServer': 'Desbloquear servidor',
'setup.welcome.telemetryNotice.description':
'Pulse envía un pequeño resumen diario de uso que muestra qué versiones están desplegadas y qué funciones y plataformas se usan, para que el desarrollo se centre donde Pulse se usa de verdad. Contiene conteos y flags de activado/desactivado con un ID de instalación seudónimo rotativo, nunca hostnames, credenciales, direcciones IP ni nada que escribas. Nunca se vende, se comparte ni se usa para publicidad, y no está vinculado a una cuenta o licencia de Pulse. Está activado por defecto. Decides en el siguiente paso y puedes cambiarlo en cualquier momento en Ajustes.',
'setup.welcome.telemetryNotice.title': 'Estadísticas de uso',
'setup.wizard.ariaLabel': 'Asistente de configuración de Pulse',
'setup.welcome.action.continueSecurity': 'Continuar a Seguridad',
'setup.welcome.action.verifyToken': 'Verificar token de bootstrap',
@@ -322,10 +330,7 @@ export const ES_MESSAGE_OVERRIDES = {
'setup.welcome.hero.title': 'Bienvenido a Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Pega tu token de bootstrap',
'setup.welcome.success.commandCopied': 'Comando copiado al portapapeles',
'setup.welcome.telemetryNotice.description':
'La telemetría de uso saliente está activada por defecto. Pulse envía un ping de inicio retrasado y un heartbeat diario con un ID de instalación seudónimo rotativo, detalles de versión/runtime, conteos agregados y flags de funciones. Para desactivarla antes de cualquier ping, define PULSE_TELEMETRY=false antes de iniciar Pulse. También puedes desactivarla luego en Ajustes.',
'setup.welcome.telemetryNotice.detailsLink': 'Detalles completos',
'setup.welcome.telemetryNotice.title': 'La telemetría de uso está activada por defecto',
'setup.welcome.tokenHelp.afterVerify':
'Después de que Pulse verifique este token, el siguiente paso es crear la cuenta de administrador para este servidor.',
'setup.welcome.tokenHelp.docker':
@@ -378,23 +383,14 @@ export const ES_MESSAGE_OVERRIDES = {
'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.telemetry.description':
'Ayuda a mejorar Pulse compartiendo un resumen diario de uso. Vista previa del payload muestra exactamente lo que se enviaría: un ID de instalación seudónimo rotativo, identidad de versión normalizada, plataforma de ejecución, categorías generales de método de despliegue y ciclo de vida, conteos agregados de recursos y resultados, flags generales de funciones, contadores sin contenido de uso de Patrol, Assistant y API de capacidades, y cuatro conteos de adopción del historial de cargas de trabajo deduplicados por sesión. Nunca incluye hostnames, credenciales, identificadores de infraestructura, URLs, rutas, configuración regional, eventos del navegador, datos de clickstream, prompts, mensajes de chat, texto de comandos, salida de acciones, valores de tokens, nombres, direcciones de email ni direcciones IP. Nunca se vende, se comparte con nadie ni se usa para publicidad, y no está vinculado a una cuenta o licencia de Pulse. Las filas de telemetría se conservan hasta 90 días, y las direcciones IP de las solicitudes se usan solo transitoriamente para rate limiting y no se guardan en filas de telemetría.',
'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: un ID de instalación seudónimo rotativo, identidad de versión normalizada, plataforma de ejecución, categorías generales de método de despliegue y ciclo de vida, conteos agregados de recursos y resultados, flags generales de funciones y contadores sin contenido de uso de Patrol, Assistant y API de capacidades. El payload no incluye hostnames, credenciales, identificadores de infraestructura, URLs, rutas, configuración regional, eventos del navegador, prompts, mensajes de chat, texto de comandos, salida de acciones, valores de tokens, nombres, direcciones de email ni direcciones IP. Las filas de telemetría se conservan hasta 90 días, y las direcciones IP de las solicitudes se usan solo transitoriamente para rate limiting y 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.notice.description':
'Pulse ahora añade señales generales de despliegue, ciclo de vida y tamaño de la instalación, además de resultados agregados de alertas y notificaciones, cuando la telemetría de uso saliente está activada. Sigue excluyendo datos personales, identificadores de infraestructura, contenido, eventos del navegador y datos de clickstream.',
'settings.general.telemetry.notice.disable': 'Desactivar telemetría',
'settings.general.telemetry.notice.dismissLabel':
'Descartar aviso de actualización del payload de telemetría',
'settings.general.telemetry.notice.dismissTitle': 'Descartar permanentemente',
'settings.general.telemetry.notice.preview': 'Vista previa del payload',
'settings.general.telemetry.notice.privacy': 'Detalles de privacidad',
'settings.general.telemetry.notice.title': 'Payload de telemetría actualizado',
'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',
+13 -20
View File
@@ -84,6 +84,8 @@ export const EN_MESSAGES = {
'setup.security.error.passwordRequired': 'Please enter a password',
'setup.security.error.passwordTooShort': 'Password must be at least 12 characters',
'setup.security.error.setupFailed': 'Setup failed: {error}',
'setup.security.error.telemetryDisableFailed':
'Your admin account was created, but usage statistics could not be turned off. You can turn them off in Settings → System → General.',
'setup.security.generatedPasswordHelp':
'A secure 20-character password will be generated and shown on the next screen.',
'setup.security.label.confirmPassword': 'Confirm password',
@@ -99,10 +101,16 @@ export const EN_MESSAGES = {
'setup.security.placeholder.username': 'admin',
'setup.security.showPassword.hide': 'Hide',
'setup.security.showPassword.show': 'Show',
'setup.security.telemetry.description':
'Send a small daily usage summary so the features and platforms you rely on get priority. It holds counts and on/off flags with a rotating pseudonymous install ID, never hostnames, credentials, or IP addresses. It is never sold or shared and is not linked to a Pulse account. Change this at any time in Settings.',
'setup.security.telemetry.title': 'Usage statistics',
'setup.security.title': 'Create your admin account',
'setup.step.firstSource': 'First source',
'setup.step.security': 'Security',
'setup.step.unlockServer': 'Unlock server',
'setup.welcome.telemetryNotice.description':
'Pulse sends a small daily usage summary showing which releases are deployed and which features and platforms are in use, so development effort goes where Pulse is actually used. It holds counts and on/off flags with a rotating pseudonymous install ID, never hostnames, credentials, IP addresses, or anything you type. It is never sold, shared, or used for advertising, and it is not linked to a Pulse account or license. It is on by default. You choose on the next step and can change it at any time in Settings.',
'setup.welcome.telemetryNotice.title': 'Usage statistics',
'setup.wizard.ariaLabel': 'Pulse Setup Wizard',
'alerts.activation.label.enabled': 'Notifications enabled',
'alerts.activation.label.disabled': 'Notifications paused',
@@ -314,10 +322,7 @@ export const EN_MESSAGES = {
'setup.welcome.hero.title': 'Welcome to Pulse',
'setup.welcome.placeholder.bootstrapToken': 'Paste your bootstrap token',
'setup.welcome.success.commandCopied': 'Command copied to clipboard',
'setup.welcome.telemetryNotice.description':
'Outbound usage telemetry is on by default. Pulse sends a delayed startup ping and daily heartbeat with a rotating pseudonymous install ID, release/runtime details, aggregate counts, and feature flags. To disable it before any ping, set PULSE_TELEMETRY=false before starting Pulse. You can also turn it off later in Settings.',
'setup.welcome.telemetryNotice.detailsLink': 'Full details',
'setup.welcome.telemetryNotice.title': 'Usage telemetry is enabled by default',
'setup.welcome.tokenHelp.afterVerify':
'After Pulse verifies this token, the next step is creating the admin account for this server.',
'setup.welcome.tokenHelp.docker':
@@ -369,22 +374,14 @@ export const EN_MESSAGES = {
'settings.general.monitoringCadence.section.description':
'Control how frequently Pulse polls Proxmox VE nodes.',
'settings.general.monitoringCadence.section.title': 'Monitoring cadence',
'settings.general.telemetry.description':
'Help improve Pulse by sharing a daily usage summary. Preview payload shows exactly what would be sent: a rotating pseudonymous install ID, normalized release identity, runtime platform, coarse deployment and lifecycle buckets, aggregate resource and outcome counts, coarse feature flags, content-free Patrol, Assistant, and capability-API usage counters, and four session-deduplicated workload-history adoption counts. It never includes hostnames, credentials, infrastructure identifiers, URLs, paths, locale, raw browser events, an event-level clickstream, prompts, chat messages, command text, action output, token values, names, email addresses, or IP addresses. It is never sold, shared with anyone else, or used for advertising, and it is not linked to a Pulse account or license. Telemetry rows are retained for up to 90 days, and request IP addresses are used only transiently for rate limiting and are not stored in telemetry rows.',
'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 outbound usage data: a rotating pseudonymous install ID, normalized release identity, runtime platform, coarse deployment and lifecycle buckets, aggregate resource and outcome counts, coarse feature flags, content-free Patrol, Assistant, and capability-API usage counters, and four session-deduplicated workload-history adoption counts. The payload does not include hostnames, credentials, infrastructure identifiers, URLs, paths, locale, raw browser events, an event-level clickstream, prompts, chat messages, command text, action output, token values, names, email addresses, or IP addresses. Telemetry rows are retained for up to 90 days, and request IP addresses are used only transiently for rate limiting and 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.notice.description':
'Pulse now adds four content-free, session-deduplicated workload-history adoption counts when outbound usage telemetry is enabled. It still excludes personal details, infrastructure IDs, content, raw browser events, and event-level clickstream data.',
'settings.general.telemetry.notice.disable': 'Disable telemetry',
'settings.general.telemetry.notice.dismissLabel': 'Dismiss telemetry payload update',
'settings.general.telemetry.notice.dismissTitle': 'Dismiss permanently',
'settings.general.telemetry.notice.preview': 'Preview payload',
'settings.general.telemetry.notice.privacy': 'Privacy details',
'settings.general.telemetry.notice.title': 'Telemetry payload updated',
'settings.general.telemetry.payloadAriaLabel': 'Telemetry payload preview',
'settings.general.telemetry.payloadTitle': 'Current heartbeat payload',
'settings.general.telemetry.previewPayload': 'Preview payload',
@@ -578,13 +575,6 @@ export const SETTINGS_GENERAL_MIGRATED_MESSAGE_KEYS = [
'settings.general.telemetry.description',
'settings.general.telemetry.disabledPreview',
'settings.general.telemetry.fullDetails',
'settings.general.telemetry.notice.description',
'settings.general.telemetry.notice.disable',
'settings.general.telemetry.notice.dismissLabel',
'settings.general.telemetry.notice.dismissTitle',
'settings.general.telemetry.notice.preview',
'settings.general.telemetry.notice.privacy',
'settings.general.telemetry.notice.title',
'settings.general.telemetry.payloadAriaLabel',
'settings.general.telemetry.payloadTitle',
'settings.general.telemetry.previewPayload',
@@ -672,6 +662,7 @@ export const FIRST_SESSION_MONITORING_MIGRATED_MESSAGE_KEYS = [
'setup.security.error.passwordRequired',
'setup.security.error.passwordTooShort',
'setup.security.error.setupFailed',
'setup.security.error.telemetryDisableFailed',
'setup.security.generatedPasswordHelp',
'setup.security.label.confirmPassword',
'setup.security.label.password',
@@ -686,6 +677,8 @@ export const FIRST_SESSION_MONITORING_MIGRATED_MESSAGE_KEYS = [
'setup.security.placeholder.username',
'setup.security.showPassword.hide',
'setup.security.showPassword.show',
'setup.security.telemetry.description',
'setup.security.telemetry.title',
'setup.security.title',
'setup.step.firstSource',
'setup.step.security',
@@ -101,7 +101,7 @@ describe('localStorage signals', () => {
});
it('rejects a second low-priority notice owner in the same session', () => {
expect(reserveLowPriorityNoticeSession('telemetry-update')).toBe(true);
expect(reserveLowPriorityNoticeSession('release-update')).toBe(true);
expect(reserveLowPriorityNoticeSession('github-star')).toBe(false);
});
});
+1 -2
View File
@@ -138,7 +138,7 @@ export function createLocalStorageStringSignal(
);
}
export type LowPriorityNoticeOwner = 'github-star' | 'release-update' | 'telemetry-update';
export type LowPriorityNoticeOwner = 'github-star' | 'release-update';
export const SESSION_STORAGE_KEYS = {
LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner',
@@ -190,7 +190,6 @@ export const STORAGE_KEYS = {
// Updates
UPDATES: 'pulse-updates',
WHATS_NEW_LAST_SEEN: 'pulseWhatsNewLastSeen',
TELEMETRY_PAYLOAD_NOTICE_SEEN: 'pulseTelemetryPayloadNoticeSeen',
// Alert settings
ALERT_HISTORY_TIME_FILTER: 'alertHistoryTimeFilter',
+32
View File
@@ -1334,3 +1334,35 @@ func TestBuildPingCarriesNodeTestCounts(t *testing.T) {
t.Fatalf("node_test_failures_30d = %d, want 4", ping.NodeTestFailures30d)
}
}
// TestTelemetryPrivacyDocsDiscloseSetupChoiceAndPayloadChanges pins the two
// disclosure surfaces that replaced the in-app payload-update banner: the
// first-run setup choice and the dated payload changelog. The changelog must
// carry a row for the current schema so a bump cannot land undisclosed.
func TestTelemetryPrivacyDocsDiscloseSetupChoiceAndPayloadChanges(t *testing.T) {
for _, relativePath := range []string{
filepath.Join("..", "..", "docs", "PRIVACY.md"),
filepath.Join("..", "..", "frontend-modern", "public", "docs", "PRIVACY.md"),
} {
raw, err := os.ReadFile(relativePath)
if err != nil {
t.Fatalf("read %s: %v", relativePath, err)
}
content := string(raw)
for _, required := range []string{
"During first-run setup, switch off **Usage statistics** on the admin-account step",
"The first startup ping is sent about two minutes after Pulse starts",
"#### Payload changes",
"Every change to the payload bumps the schema version, is listed here with its date, and appears in the release notes",
"An in-app notice is reserved for a change in kind",
"#### What it is not used for",
"It is not sold, licensed, or shared with anyone else",
"It is not linked to a Pulse account, license key, purchase, or email address",
fmt.Sprintf("| %d | 2026-", TelemetrySchemaVersion),
} {
if !strings.Contains(content, required) {
t.Errorf("%s must disclose %q", relativePath, required)
}
}
}
}