diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 58de776f83..0b3398133a 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -1,88 +1,122 @@
-# 🚚 Migrating Pulse
+# Migrating Pulse
-This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow.
+**Create Backup** in **Settings → System → Recovery** creates an encrypted
+configuration export, not a full backup of the installation. Check its scope
+before moving Pulse or retiring the old server. Do not use it alone when you
+need to preserve history, agent enrolment state or settings outside that scope.
-## 🚀 Quick Migration Guide
+## Choose the backup you need
-### ❌ DON'T: Copy Files
-Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break.
+### Configuration transfer
-### ✅ DO: Use Export/Import
+Use export/import to move the included settings between installations. Pulse
+decrypts the export with its passphrase and saves the imported credentials with
+the destination's encryption key. Do not copy individual encrypted files into
+a fresh installation with a different key.
-#### 1. Export (Old Server)
-1. Go to **Settings → System → Recovery**.
-2. Click **Create Backup**.
-3. Enter a strong passphrase and download the encrypted backup.
+The current export format includes:
-#### 2. Import (New Server)
-1. Install a fresh Pulse instance.
-2. Go to **Settings → System → Recovery**.
-3. Click **Restore Configuration** and upload your file.
-4. Enter the passphrase.
-
-## 📦 What Gets Migrated
-
-| Included ✅ | Not Included ❌ |
+| Included | Not included |
| :--- | :--- |
-| Nodes & credentials | Historical metrics history (`metrics.db`) |
-| Alerts & overrides | Browser sessions and local cookies |
-| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) |
-| System settings (`system.json`) | Update history/backup folders |
-| API token records | — |
-| OIDC config | — |
-| SSO / SAML config | — |
-| TrueNAS connections (`truenas.enc`) | — |
-| Guest metadata/notes | — |
-| — | Relay config (`relay.enc`) — re-enable in Settings |
-| — | Host metadata (notes/tags/AI command overrides) |
-| — | Docker metadata cache |
-| — | Agent profiles and assignments |
-| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
-| — | RBAC roles (`rbac_roles.json`) — re-create after import |
-| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) |
-| — | Server sessions (`sessions.json`) |
-| — | Update history (`update-history.jsonl`) |
+| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections |
+| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history |
+| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules |
+| System settings | Local login credentials, environment overrides and sessions |
+| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state |
+| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles |
+| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments |
+| — | AI settings, findings, usage and chat history |
+| — | Licence files, existing Relay configuration, binaries, service definitions and update backups |
-## 🔄 Common Scenarios
+Contents depend on the version that created the export. Older formats can omit
+settings included above. Imported API-token records retain their scopes and
+expiry; they do not reveal the original tokens or transfer every agent's state.
+SSO configuration **is included**, even though local login credentials are not.
-### Moving to New Hardware
-Export from old → Install new → Import.
+### Full-state recovery
-### Docker ↔ Systemd ↔ Kubernetes
-The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly.
+If you need the same installation's history and excluded state, retain a
+consistent filesystem/volume backup as well. Stop Pulse before taking that
+backup; monitoring and alert delivery pause while it is stopped. Preserve every
+effective data path, the matching `.encryption.key`, audit data and signing key,
+environment files, external certificates, binaries and service/container
+configuration together. Custom paths and organisation directories matter.
+Copying a live database file alone can omit its sidecar files.
-### Disaster Recovery
-1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)).
-2. Import your latest backup.
-3. Restored in < 5 minutes.
+Keep that backup private and test recovery on an isolated destination before
+retiring the source. Do not mix encrypted files and keys from different
+installations or restore over a running Pulse. See [configuration
+paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage).
+The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains)
+is a third, limited backup; it is not a substitute for either procedure above.
-## 📋 Post-Migration Checklist
+## Transfer the included configuration
-Because local login credentials are stored in `.env` (not part of exports), you must:
+1. On the old server, sign in and open **Settings → System → Recovery →
+ Create Backup**. Use a strong, unique passphrase of at least 12 characters
+ and store it separately from the downloaded file. With password login,
+ choose **Use a custom passphrase** if you do not want to use that password.
+ Keep the old instance and your separate full-state backup until validation
+ is complete.
+2. Install Pulse at the destination using the [installation guide](INSTALL.md).
+ Configure destination-local administrator access before importing. Keep the
+ destination isolated from agents and notification destinations during the
+ trial so two servers do not send duplicate alerts or remote actions.
+3. If the destination already has useful settings, export them first. Open
+ **Settings → System → Recovery → Restore Configuration**, select the file
+ and enter its passphrase. Import replaces the included configuration and
+ API-token records; it does not merge them with the destination's records.
+4. Check the result before changing DNS or agent addresses. A success message
+ establishes neither full recovery nor fresh monitoring. If import reports a
+ reload/apply failure, settings may already have been written: preserve both
+ installations and inspect the error before repeating it.
-1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
-2. **Confirm API access**:
- * If you created API tokens in the UI, those token records are included in the export and should continue working.
-3. **Update Agents**:
- * **Unified Agent**: Update the `--token` flag in your service definition.
- * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
- * **New server address?** Agents dial the address they were installed
- with (the `--url` flag, or `PULSE_URL` for containerized agents), so
- agents disappear from the new instance until that value is updated in
- each agent's service definition. Fronting Pulse with a DNS name
- instead of an IP address makes future moves invisible to agents.
- * *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.*
-4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
+Keep the archive, passphrase, configuration files and credentials out of issue
+threads. Use the authenticated settings form rather than a command containing
+a password, API token or session cookie.
-## 🔒 Security
+## Cut over and verify
-* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM).
-* **Storage**: Safe to store in cloud backups or password managers.
-* **Passphrase**: Use a strong, unique passphrase (min 12 chars).
+- **Login and permissions:** verify destination-local administrator access and
+ any imported SSO provider, group mappings and callback address. Re-create
+ custom roles and supply external CA/certificate files if needed. Environment
+ overrides can take precedence over imported settings.
+- **Connections and history:** check every expected platform, guest and backup
+ against a fresh observation, not a cached row. Re-create excluded TrueNAS,
+ vSphere and Machine Availability connections. Missing historical data after
+ configuration-only transfer is not proof that collection has failed.
+- **Agents:** preserve each agent's existing identity and private credential.
+ With a changed server address, use the [agent retargeting
+ guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host.
+ Do not create another token just for a server move or paste it into `--token`.
+ Confirm fresh reports and any enrolment/authorisation result; imported token
+ records alone do not prove that every agent is admitted. Re-create excluded
+ profiles and assignments before enabling remote actions.
+- **Alerts and notifications:** avoid running both instances against the same
+ destinations. After cutover, check thresholds, schedules and one controlled
+ notification test with its actual delivery result. Importing destination
+ settings does not migrate pending notifications or incident history.
+- **Licence and remaining settings:** re-activate Pro if required and verify
+ any excluded settings you rely on. Existing paired phones need their retained
+ configuration; configuration export alone is not a Mobile/Relay migration.
+ Relay is no longer sold; do not enable it as a new migration route.
-## 🔧 Troubleshooting
+Retire the old installation only after these checks succeed. Keep the private,
+consistent pre-move backup for recovery. If validation fails, stop the trial
+destination before returning traffic to the intact source; check agent URLs
+and avoid two active writers. There is no guaranteed five-minute recovery time.
-* **"Invalid passphrase"**: Ensure exact match (case-sensitive).
-* **Missing Nodes**: Verify export date.
-* **Connection Errors**: Update node IPs in Settings if they changed.
-* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed.
+## Troubleshooting
+
+- **Wrong passphrase or rejected file:** use the original export file and its
+ exact passphrase. Do not delete destination data or regenerate encryption
+ keys to make an import succeed.
+- **A setting is missing:** compare it with the export scope above and the
+ exporting version. Use the old installation or full-state backup rather
+ than assuming import contained it.
+- **Agents are absent:** check their primary URL, TLS trust and fresh admission
+ evidence using the linked guide. Do not uninstall, re-enrol or delete saved
+ identities just because the dashboard is empty.
+- **Need help:** retain the source/destination versions, deployment types,
+ operation time and exact redacted error. Follow [safe
+ reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup.
diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md
index adf8371792..405e50271d 100644
--- a/docs/UNIFIED_AGENT.md
+++ b/docs/UNIFIED_AGENT.md
@@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse.
### Moving Pulse to a new address
-Configuration export/import restores the server-side agent records and API
-tokens. It cannot rewrite the primary Pulse URL on remote machines because
-agents initiate the connection. Prefer a stable DNS name for the primary URL
-so replacing the Pulse host does not require an agent migration.
+Configuration export/import restores API-token records, not the server-side
+agent inventory, enrolment state, profiles or assignments. See the
+[configuration-transfer scope](MIGRATION.md#configuration-transfer) before
+retiring the old server. Import cannot rewrite the primary Pulse URL on remote
+machines because agents initiate the connection. Prefer a stable DNS name for
+the primary URL, but still verify fresh reports and agent admission after a move.
After importing the configuration on a Pulse server with a different address,
retarget each existing standard Linux agent from that agent machine. Download
diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs
new file mode 100644
index 0000000000..448ff8c78e
--- /dev/null
+++ b/frontend-modern/browser-tests/configuration-migration.cjs
@@ -0,0 +1,140 @@
+// Run from the assigned root with pulse-worker-browser. This checks the real
+// dialog/flow and shipped guides, not a native migration or release delivery.
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const { createHash } = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+(async () => {
+ const workspace = process.cwd();
+ const root = path.join(workspace, 'frontend-modern');
+ const output = path.join(workspace, 'tmp', 'configuration-migration');
+ fs.mkdirSync(output, { recursive: true });
+ process.chdir(root);
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root, configFile: path.join(root, 'vite.config.ts'),
+ plugins: [{ name: 'migration-doc-harness', configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (/^\/docs\/[^?]+$/.test(req.url || '') && !req.url.endsWith('.md'))
+ req.url = '/browser-tests/configuration-migration.html';
+ next();
+ });
+ } }],
+ server: { host: '127.0.0.1', port: 5264, strictPort: true },
+ });
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [engine, width, height] of [['chromium', 1440, 1000], ['webkit', 390, 844]]) {
+ browser = await (engine === 'chromium' ? chromium : webkit).launch(engine === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true });
+ const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true,
+ ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
+ const errors = [], operations = [];
+ await context.route('**/api/**', async (route) => {
+ const req = route.request();
+ if (req.url().endsWith('/api/config/export')) {
+ operations.push({ path: '/api/config/export', method: req.method(), body: req.postDataJSON() });
+ await route.fulfill({ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ status: 'success', data: 'synthetic-archive-not-a-real-backup' }) });
+ } else if (req.url().endsWith('/api/config/import')) {
+ operations.push({ path: '/api/config/import', method: req.method(), body: req.postDataJSON() });
+ // Deliberately reject this synthetic archive: warning, error and
+ // cancel paths must remain available without pretending to migrate.
+ await route.fulfill({ status: 400, contentType: 'text/plain', body: 'Synthetic rejected archive' });
+ } else await route.fulfill({ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ hasAuthentication: false }) });
+ });
+ context.on('page', (p) => p.on('pageerror', (err) => errors.push(err.message)));
+ const page = await context.newPage();
+ await page.goto('http://127.0.0.1:5264/browser-tests/configuration-migration.html');
+ if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
+ await page.getByRole('button', { name: 'Create Backup', exact: true }).click();
+ const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true });
+ await exporting.waitFor();
+ await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
+ let text = await exporting.innerText();
+ for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records',
+ 'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included'])
+ assert.ok(text.includes(phrase), phrase);
+ assert.ok(await exporting.getByRole('button', { name: 'Export', exact: true }).isDisabled());
+ const guide = exporting.getByRole('link', { name: 'migration guide', exact: true });
+ await guide.scrollIntoViewIfNeeded();
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) });
+ await guide.focus();
+ const popupPromise = page.waitForEvent('popup');
+ await page.keyboard.press('Enter');
+ const docs = await popupPromise;
+ await docs.locator('article').waitFor();
+ const article = await docs.locator('article').innerText();
+ for (const phrase of ['not a full backup', 'TrueNAS, vSphere and Machine Availability',
+ 'SSO configuration is included', 'Stop Pulse', 'does not merge', 'reload/apply failure'])
+ assert.ok(article.includes(phrase), phrase);
+ assert.ok(docs.url().endsWith('/docs/MIGRATION'));
+ const scope = docs.locator('#configuration-transfer');
+ await scope.scrollIntoViewIfNeeded();
+ assert.ok(await docs.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ await docs.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-scope.png`) });
+ const retarget = docs.getByRole('link', { name: 'agent retargeting guide', exact: true });
+ await retarget.focus();
+ await docs.keyboard.press('Enter');
+ await docs.waitForFunction(() => document.activeElement?.id === 'moving-pulse-to-a-new-address');
+ assert.ok((await docs.locator('article').innerText()).includes('restores API-token records, not the server-side'));
+ assert.ok(docs.url().endsWith('/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address'));
+ await docs.close();
+ await exporting.getByLabel('Encryption Passphrase', { exact: true }).fill('synthetic-migration-passphrase');
+ const downloadPromise = page.waitForEvent('download');
+ await exporting.getByRole('button', { name: 'Export', exact: true }).click();
+ const download = await downloadPromise;
+ assert.deepEqual(JSON.parse(fs.readFileSync(await download.path(), 'utf8')),
+ { status: 'success', data: 'synthetic-archive-not-a-real-backup' });
+ await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click();
+ const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true });
+ await importing.waitFor();
+ await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
+ text = await importing.innerText();
+ for (const phrase of ['Back up the destination first', 'not the whole installation',
+ 'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission'])
+ assert.ok(text.includes(phrase), phrase);
+ assert.ok(!text.includes('restores server-side agent records'));
+ await importing.getByRole('link', { name: 'migration guide' }).scrollIntoViewIfNeeded();
+ await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-import.png`) });
+ await importing.getByLabel('Backup Password', { exact: true }).fill('synthetic-migration-passphrase');
+ await importing.getByLabel('Configuration File', { exact: true }).setInputFiles({ name: 'synthetic-backup.json',
+ mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ data: 'synthetic-archive-not-a-real-backup' })) });
+ const rejectedResponse = page.waitForResponse((r) => r.url().endsWith('/api/config/import'));
+ await importing.getByRole('button', { name: 'Import', exact: true }).click();
+ await rejectedResponse;
+ assert.equal(operations.length, 2);
+ assert.deepEqual(operations[1], { path: '/api/config/import', method: 'POST',
+ body: { passphrase: 'synthetic-migration-passphrase', data: 'synthetic-archive-not-a-real-backup' } });
+ assert.ok(await importing.isVisible(), 'failed import retains the dialog');
+ await importing.getByRole('button', { name: 'Cancel', exact: true }).click();
+ await importing.waitFor({ state: 'hidden' });
+ assert.equal(operations.length, 2, 'copy/navigation/cancel never send another operation');
+ assert.deepEqual(errors, []);
+ observations.push({ engine, browserVersion: browser.version(), width, height,
+ warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true,
+ noPageOverflow: true, errors });
+ await browser.close();
+ browser = undefined;
+ }
+ const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx',
+ 'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md'];
+ const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
+ baseSha: 'b62042798c7c9b326408dd5532d7fa330d6bb089',
+ scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment',
+ contentSha256: Object.fromEntries(files.map((file) => [file,
+ createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations };
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify(result));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
diff --git a/frontend-modern/browser-tests/configuration-migration.html b/frontend-modern/browser-tests/configuration-migration.html
new file mode 100644
index 0000000000..b27dd2abbd
--- /dev/null
+++ b/frontend-modern/browser-tests/configuration-migration.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Configuration transfer safety
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/configuration-migration.tsx b/frontend-modern/browser-tests/configuration-migration.tsx
new file mode 100644
index 0000000000..275f2a2902
--- /dev/null
+++ b/frontend-modern/browser-tests/configuration-migration.tsx
@@ -0,0 +1,32 @@
+// Actual transfer dialogs and flow, plus the production Docs renderer/router.
+// The browser driver supplies only synthetic API responses; no server or
+// operational credentials are involved in this copy/interaction proof.
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { BackupTransferDialogs } from '../src/components/Settings/BackupTransferDialogs';
+import { useBackupTransferFlow } from '../src/components/Settings/useBackupTransferFlow';
+import Docs from '../src/pages/Docs';
+import '../src/index.css';
+
+function Transfer() {
+ const securityStatus = () => null;
+ const flow = useBackupTransferFlow({ securityStatus });
+ return (
+
+
Configuration transfer
+
+
+
+
+ );
+}
+
+render(
+ () => (
+
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index bd2176689c..175cc2ec76 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,52 +1,72 @@
{
"version": 1,
"result": "passed",
- "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
- "verified_at": "2026-10-03T04:13:25.499572Z",
+ "base_sha": "b62042798c7c9b326408dd5532d7fa330d6bb089",
+ "verified_at": "2026-10-03T14:52:51.979841Z",
"changed_paths": [
- "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
- "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
+ "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx"
],
"content_sha256": {
- "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
- "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
+ "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341"
},
"routes": [
- "Existing PBS/resource detail Overview change history",
- "Existing resource detail Manage action history"
+ "/settings/system/recovery (real transfer dialog/flow harness)",
+ "/docs/MIGRATION",
+ "/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address"
+ ],
+ "states": [
+ "Configuration export: includes SSO/API-token records, excludes history/TrueNAS/vSphere/agent enrolment; local login/sessions excluded",
+ "Import: replaces included settings, preserves agent identity and requires fresh admission",
+ "Desktop light Chromium and touch phone dark WebKit warnings readable; all six final screenshots inspected"
+ ],
+ "interactions": [
+ "Open Create Backup and Restore Configuration dialogs",
+ "Keyboard-open migration guide without losing dialog state, then follow agent retargeting guide",
+ "Synthetic export download through actual flow; deliberately rejected synthetic import retains dialog and Cancel makes no extra operation"
],
"viewports": [
{
- "width": 1365,
- "height": 900
+ "width": 1440,
+ "height": 1000
},
{
"width": 390,
"height": 844
}
],
- "states": [
- "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
- "Denied filtered facets do not fall back to the unfiltered bundle",
- "Retry retains denial while pending; fresh successful response restores events and clears guidance",
- "First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
- "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
- "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
- ],
- "interactions": [
- "Remount full production resource drawer with denied/transient reads",
- "Activate scoped Retry with keyboard Enter",
- "Apply and clear Change kind filter",
- "Switch between Overview and Manage"
- ],
"limitations": [
- "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
- "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
- "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
- "Independent reader authorisation remains independent; this is not a global session revocation broadcast."
- ]
+ "Synthetic API and dedicated real-component/router harness, not native migration, licence, agent admission or shipment",
+ "Final capture waits for real dialog animation; preceding state checks passed but blank transition captures did not establish visual proof"
+ ],
+ "browser_versions": [
+ {
+ "engine": "chromium",
+ "browserVersion": "141.0.7390.37",
+ "width": 1440,
+ "height": 1000,
+ "warningScope": true,
+ "keyboardGuideAndRetargetLinks": true,
+ "syntheticExportAndRejectedImport": true,
+ "noPageOverflow": true,
+ "errors": []
+ },
+ {
+ "engine": "webkit",
+ "browserVersion": "26.0",
+ "width": 390,
+ "height": 844,
+ "warningScope": true,
+ "keyboardGuideAndRetargetLinks": true,
+ "syntheticExportAndRejectedImport": true,
+ "noPageOverflow": true,
+ "errors": []
+ }
+ ],
+ "playwright_version": "1.56.1",
+ "shipped_asset_sha256": {
+ "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341",
+ "frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9",
+ "frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05"
+ },
+ "browser_script": "frontend-modern/browser-tests/configuration-migration.cjs"
}
diff --git a/frontend-modern/public/docs/MIGRATION.md b/frontend-modern/public/docs/MIGRATION.md
index 58de776f83..0b3398133a 100644
--- a/frontend-modern/public/docs/MIGRATION.md
+++ b/frontend-modern/public/docs/MIGRATION.md
@@ -1,88 +1,122 @@
-# 🚚 Migrating Pulse
+# Migrating Pulse
-This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow.
+**Create Backup** in **Settings → System → Recovery** creates an encrypted
+configuration export, not a full backup of the installation. Check its scope
+before moving Pulse or retiring the old server. Do not use it alone when you
+need to preserve history, agent enrolment state or settings outside that scope.
-## 🚀 Quick Migration Guide
+## Choose the backup you need
-### ❌ DON'T: Copy Files
-Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break.
+### Configuration transfer
-### ✅ DO: Use Export/Import
+Use export/import to move the included settings between installations. Pulse
+decrypts the export with its passphrase and saves the imported credentials with
+the destination's encryption key. Do not copy individual encrypted files into
+a fresh installation with a different key.
-#### 1. Export (Old Server)
-1. Go to **Settings → System → Recovery**.
-2. Click **Create Backup**.
-3. Enter a strong passphrase and download the encrypted backup.
+The current export format includes:
-#### 2. Import (New Server)
-1. Install a fresh Pulse instance.
-2. Go to **Settings → System → Recovery**.
-3. Click **Restore Configuration** and upload your file.
-4. Enter the passphrase.
-
-## 📦 What Gets Migrated
-
-| Included ✅ | Not Included ❌ |
+| Included | Not included |
| :--- | :--- |
-| Nodes & credentials | Historical metrics history (`metrics.db`) |
-| Alerts & overrides | Browser sessions and local cookies |
-| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) |
-| System settings (`system.json`) | Update history/backup folders |
-| API token records | — |
-| OIDC config | — |
-| SSO / SAML config | — |
-| TrueNAS connections (`truenas.enc`) | — |
-| Guest metadata/notes | — |
-| — | Relay config (`relay.enc`) — re-enable in Settings |
-| — | Host metadata (notes/tags/AI command overrides) |
-| — | Docker metadata cache |
-| — | Agent profiles and assignments |
-| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
-| — | RBAC roles (`rbac_roles.json`) — re-create after import |
-| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) |
-| — | Server sessions (`sessions.json`) |
-| — | Update history (`update-history.jsonl`) |
+| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections |
+| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history |
+| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules |
+| System settings | Local login credentials, environment overrides and sessions |
+| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state |
+| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles |
+| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments |
+| — | AI settings, findings, usage and chat history |
+| — | Licence files, existing Relay configuration, binaries, service definitions and update backups |
-## 🔄 Common Scenarios
+Contents depend on the version that created the export. Older formats can omit
+settings included above. Imported API-token records retain their scopes and
+expiry; they do not reveal the original tokens or transfer every agent's state.
+SSO configuration **is included**, even though local login credentials are not.
-### Moving to New Hardware
-Export from old → Install new → Import.
+### Full-state recovery
-### Docker ↔ Systemd ↔ Kubernetes
-The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly.
+If you need the same installation's history and excluded state, retain a
+consistent filesystem/volume backup as well. Stop Pulse before taking that
+backup; monitoring and alert delivery pause while it is stopped. Preserve every
+effective data path, the matching `.encryption.key`, audit data and signing key,
+environment files, external certificates, binaries and service/container
+configuration together. Custom paths and organisation directories matter.
+Copying a live database file alone can omit its sidecar files.
-### Disaster Recovery
-1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)).
-2. Import your latest backup.
-3. Restored in < 5 minutes.
+Keep that backup private and test recovery on an isolated destination before
+retiring the source. Do not mix encrypted files and keys from different
+installations or restore over a running Pulse. See [configuration
+paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage).
+The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains)
+is a third, limited backup; it is not a substitute for either procedure above.
-## 📋 Post-Migration Checklist
+## Transfer the included configuration
-Because local login credentials are stored in `.env` (not part of exports), you must:
+1. On the old server, sign in and open **Settings → System → Recovery →
+ Create Backup**. Use a strong, unique passphrase of at least 12 characters
+ and store it separately from the downloaded file. With password login,
+ choose **Use a custom passphrase** if you do not want to use that password.
+ Keep the old instance and your separate full-state backup until validation
+ is complete.
+2. Install Pulse at the destination using the [installation guide](INSTALL.md).
+ Configure destination-local administrator access before importing. Keep the
+ destination isolated from agents and notification destinations during the
+ trial so two servers do not send duplicate alerts or remote actions.
+3. If the destination already has useful settings, export them first. Open
+ **Settings → System → Recovery → Restore Configuration**, select the file
+ and enter its passphrase. Import replaces the included configuration and
+ API-token records; it does not merge them with the destination's records.
+4. Check the result before changing DNS or agent addresses. A success message
+ establishes neither full recovery nor fresh monitoring. If import reports a
+ reload/apply failure, settings may already have been written: preserve both
+ installations and inspect the error before repeating it.
-1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
-2. **Confirm API access**:
- * If you created API tokens in the UI, those token records are included in the export and should continue working.
-3. **Update Agents**:
- * **Unified Agent**: Update the `--token` flag in your service definition.
- * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
- * **New server address?** Agents dial the address they were installed
- with (the `--url` flag, or `PULSE_URL` for containerized agents), so
- agents disappear from the new instance until that value is updated in
- each agent's service definition. Fronting Pulse with a DNS name
- instead of an IP address makes future moves invisible to agents.
- * *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.*
-4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
+Keep the archive, passphrase, configuration files and credentials out of issue
+threads. Use the authenticated settings form rather than a command containing
+a password, API token or session cookie.
-## 🔒 Security
+## Cut over and verify
-* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM).
-* **Storage**: Safe to store in cloud backups or password managers.
-* **Passphrase**: Use a strong, unique passphrase (min 12 chars).
+- **Login and permissions:** verify destination-local administrator access and
+ any imported SSO provider, group mappings and callback address. Re-create
+ custom roles and supply external CA/certificate files if needed. Environment
+ overrides can take precedence over imported settings.
+- **Connections and history:** check every expected platform, guest and backup
+ against a fresh observation, not a cached row. Re-create excluded TrueNAS,
+ vSphere and Machine Availability connections. Missing historical data after
+ configuration-only transfer is not proof that collection has failed.
+- **Agents:** preserve each agent's existing identity and private credential.
+ With a changed server address, use the [agent retargeting
+ guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host.
+ Do not create another token just for a server move or paste it into `--token`.
+ Confirm fresh reports and any enrolment/authorisation result; imported token
+ records alone do not prove that every agent is admitted. Re-create excluded
+ profiles and assignments before enabling remote actions.
+- **Alerts and notifications:** avoid running both instances against the same
+ destinations. After cutover, check thresholds, schedules and one controlled
+ notification test with its actual delivery result. Importing destination
+ settings does not migrate pending notifications or incident history.
+- **Licence and remaining settings:** re-activate Pro if required and verify
+ any excluded settings you rely on. Existing paired phones need their retained
+ configuration; configuration export alone is not a Mobile/Relay migration.
+ Relay is no longer sold; do not enable it as a new migration route.
-## 🔧 Troubleshooting
+Retire the old installation only after these checks succeed. Keep the private,
+consistent pre-move backup for recovery. If validation fails, stop the trial
+destination before returning traffic to the intact source; check agent URLs
+and avoid two active writers. There is no guaranteed five-minute recovery time.
-* **"Invalid passphrase"**: Ensure exact match (case-sensitive).
-* **Missing Nodes**: Verify export date.
-* **Connection Errors**: Update node IPs in Settings if they changed.
-* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed.
+## Troubleshooting
+
+- **Wrong passphrase or rejected file:** use the original export file and its
+ exact passphrase. Do not delete destination data or regenerate encryption
+ keys to make an import succeed.
+- **A setting is missing:** compare it with the export scope above and the
+ exporting version. Use the old installation or full-state backup rather
+ than assuming import contained it.
+- **Agents are absent:** check their primary URL, TLS trust and fresh admission
+ evidence using the linked guide. Do not uninstall, re-enrol or delete saved
+ identities just because the dashboard is empty.
+- **Need help:** retain the source/destination versions, deployment types,
+ operation time and exact redacted error. Follow [safe
+ reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup.
diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md
index adf8371792..405e50271d 100644
--- a/frontend-modern/public/docs/UNIFIED_AGENT.md
+++ b/frontend-modern/public/docs/UNIFIED_AGENT.md
@@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse.
### Moving Pulse to a new address
-Configuration export/import restores the server-side agent records and API
-tokens. It cannot rewrite the primary Pulse URL on remote machines because
-agents initiate the connection. Prefer a stable DNS name for the primary URL
-so replacing the Pulse host does not require an agent migration.
+Configuration export/import restores API-token records, not the server-side
+agent inventory, enrolment state, profiles or assignments. See the
+[configuration-transfer scope](MIGRATION.md#configuration-transfer) before
+retiring the old server. Import cannot rewrite the primary Pulse URL on remote
+machines because agents initiate the connection. Prefer a stable DNS name for
+the primary URL, but still verify fresh reports and agent admission after a move.
After importing the configuration on a Pulse server with a different address,
retarget each existing standard Linux agent from that agent machine. Download
diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx
index 352b99c689..0bd52c607e 100644
--- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx
+++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx
@@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { controlClass, formField, formHelpText, labelClass } from '@/components/shared/Form';
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
import { getAPITokenManagementLocationMessage } from '@/utils/apiTokenPresentation';
+import { getShippedDocUrl } from '@/utils/docsLinks';
interface BackupTransferDialogsProps {
securityStatus: Accessor;
@@ -140,9 +141,19 @@ export const BackupTransferDialogs: Component = (pro
/>
- Important: The backup contains node credentials but NOT
- authentication settings. Each Pulse instance should configure its own login
- credentials for security. Remember your{' '}
+ Configuration only: Includes Proxmox connections, SSO settings
+ and API-token records, but not history, TrueNAS/vSphere connections or agent
+ enrolment state. Local login credentials and sessions are not included. Check
+ the{' '}
+
+ migration guide
+ {' '}
+ before relying on this for recovery. Remember your{' '}
{props.useCustomPassphrase() || !props.securityStatus()?.hasAuthentication
? 'passphrase'
: 'password'}{' '}
@@ -282,13 +293,24 @@ export const BackupTransferDialogs: Component = (pro
- Warning: Importing will replace all current configuration. This
- action cannot be undone.
+ Warning: Import replaces the included settings and API-token
+ records, not the whole installation. Back up the destination first. History and
+ excluded settings are not restored; check the{' '}
+
+ migration guide
+ {' '}
+ before retiring the old server.
- Agent migration: The backup restores server-side agent records
- and credentials, but it cannot change the Pulse URL stored on remote agents. If
- this server has a different address, retarget each agent after the import.
+ Agent migration: API-token records are included, but agent
+ inventory, enrolment state, profiles and assignments are not. Import cannot
+ change the Pulse URL stored on remote agents. Preserve each agent's identity and
+ private credential, retarget if the address changes, and verify fresh admission.