diff --git a/backend/src/__tests__/cloud-backup-routes.test.ts b/backend/src/__tests__/cloud-backup-routes.test.ts index fd8686f7..cab9e09c 100644 --- a/backend/src/__tests__/cloud-backup-routes.test.ts +++ b/backend/src/__tests__/cloud-backup-routes.test.ts @@ -3,7 +3,7 @@ * admin gating, config CRUD round-trip with secret encryption, audit logging. * The S3 SDK is mocked at the module level so no network calls happen. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; @@ -58,26 +58,146 @@ beforeEach(() => { } }); +// Sticky mocks (mockReturnValue, not mockReturnValueOnce) so a test that does +// not actually hit a tier-gated codepath doesn't leak its persona into later +// tests. The afterEach hook resets back to the Admiral baseline. +function mockCommunity() { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); +} + +function mockSkipper() { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); +} + +afterEach(() => { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); +}); + +const customConfigBody = { + provider: 'custom', + custom: { + endpoint: 'https://s3.example.com', + region: 'us-east-1', + bucket: 'b', + access_key: 'a', + secret_key: 's', + path_prefix: 'p/', + auto_upload: false, + }, +}; + describe('Cloud backup tier gating', () => { - it('rejects community tier with PAID_REQUIRED', async () => { - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community'); + // GET /config is ungated — every tier can read the stored configuration. + it('GET /config is readable on Community', async () => { + mockCommunity(); const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(200); + }); + + it('GET /config is readable on Skipper', async () => { + mockSkipper(); + const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(200); + }); + + it('GET /config is readable on Admiral', async () => { + const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('provider', 'disabled'); + }); + + // PUT /config: 'custom' is available on every tier, 'sencho' is Admiral-only. + it('PUT /config with provider=custom succeeds on Community', async () => { + mockCommunity(); + const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody); + expect(res.status).toBe(204); + }); + + it('PUT /config with provider=custom succeeds on Skipper', async () => { + mockSkipper(); + const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody); + expect(res.status).toBe(204); + }); + + it('PUT /config with provider=sencho is rejected on Community with PAID_REQUIRED', async () => { + mockCommunity(); + const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); }); - it('rejects skipper tier with ADMIRAL_REQUIRED', async () => { - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('paid'); - vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce('skipper'); - const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); + it('PUT /config with provider=sencho is rejected on Skipper with ADMIRAL_REQUIRED', async () => { + mockSkipper(); + const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' }); expect(res.status).toBe(403); expect(res.body.code).toBe('ADMIRAL_REQUIRED'); }); - it('admiral tier reaches the handler', async () => { - const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie); - expect(res.status).toBe(200); - expect(res.body).toHaveProperty('provider', 'disabled'); + // POST /provision is Admiral-only by definition (Sencho Cloud Backup activation). + it('POST /provision is rejected on Community', async () => { + mockCommunity(); + const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('POST /provision is rejected on Skipper', async () => { + mockSkipper(); + const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIRAL_REQUIRED'); + }); + + // GET /usage is Admiral-only (sencho-specific endpoint). + it('GET /usage is rejected on Community', async () => { + mockCommunity(); + const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie); + expect(res.status).toBe(403); + }); + + it('GET /usage is rejected on Skipper', async () => { + mockSkipper(); + const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie); + expect(res.status).toBe(403); + }); + + // POST /test, GET /snapshots, POST /upload, GET /status, GET /object/.../download, + // DELETE /object are gated by the *currently saved* provider. + it('POST /test reaches handler on Community when saved provider is custom', async () => { + DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom'); + mockCommunity(); + const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie); + expect(res.status).not.toBe(403); + }); + + it('POST /test is rejected on Community when saved provider is sencho', async () => { + DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho'); + mockCommunity(); + const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('GET /snapshots reaches handler on Community when saved provider is custom', async () => { + DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom'); + mockCommunity(); + const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie); + expect(res.status).not.toBe(403); + }); + + it('GET /snapshots is rejected on Community when saved provider is sencho', async () => { + DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho'); + mockCommunity(); + const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie); + expect(res.status).toBe(403); + }); + + // Admiral retains access to every endpoint. + it('Admiral can configure provider=sencho', async () => { + const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' }); + expect(res.status).toBe(204); }); }); diff --git a/backend/src/routes/cloudBackup.ts b/backend/src/routes/cloudBackup.ts index 78225403..32379fad 100644 --- a/backend/src/routes/cloudBackup.ts +++ b/backend/src/routes/cloudBackup.ts @@ -10,6 +10,22 @@ const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.'; const SECRET_REDACTED = '***'; const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']); +// Provider-aware tier gates. The managed Sencho Cloud Backup target requires +// Admiral; the bring-your-own-bucket Custom S3 target is available on every +// tier. These wrappers short-circuit to requireAdmiral only when the operation +// actually touches the 'sencho' provider. + +function gateForCurrentProvider(req: Request, res: Response): boolean { + const provider = CloudBackupService.getInstance().getProvider(); + if (provider === 'sencho') return requireAdmiral(req, res); + return true; +} + +function gateForRequestedProvider(req: Request, res: Response, requested: string): boolean { + if (requested === 'sencho') return requireAdmiral(req, res); + return true; +} + function parseSnapshotIdParam(req: Request, res: Response): number | null { const raw = req.params.id as string | undefined; const parsed = parseInt(raw ?? '', 10); @@ -43,7 +59,6 @@ export const cloudBackupRouter = Router(); cloudBackupRouter.get('/config', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; - if (!requireAdmiral(req, res)) return; try { const db = DatabaseService.getInstance(); const settings = db.getGlobalSettings(); @@ -72,7 +87,6 @@ cloudBackupRouter.get('/config', (req: Request, res: Response): void => { cloudBackupRouter.put('/config', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; try { const body = req.body ?? {}; const provider = body.provider as string | undefined; @@ -80,6 +94,7 @@ cloudBackupRouter.put('/config', (req: Request, res: Response): void => { res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' }); return; } + if (!gateForRequestedProvider(req, res, provider)) return; const db = DatabaseService.getInstance(); const crypto = CryptoService.getInstance(); db.updateGlobalSetting('cloud_backup_provider', provider); @@ -125,7 +140,7 @@ cloudBackupRouter.put('/config', (req: Request, res: Response): void => { cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; try { const result = await CloudBackupService.getInstance().testConnection(); res.json(result); @@ -171,7 +186,7 @@ cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; try { const entries = await CloudBackupService.getInstance().listCloudSnapshots(); res.json(entries); @@ -184,7 +199,7 @@ cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; const id = parseSnapshotIdParam(req, res); if (id == null) return; try { @@ -203,7 +218,7 @@ cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promi cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; const id = parseSnapshotIdParam(req, res); if (id == null) return; res.json(CloudBackupService.getInstance().getUploadStatus(id)); @@ -211,7 +226,7 @@ cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => { cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; const objectKey = decodeObjectKey(req, res); if (!objectKey) return; try { @@ -230,7 +245,7 @@ cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Resp cloudBackupRouter.delete('/object/:keyB64', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!gateForCurrentProvider(req, res)) return; const objectKey = decodeObjectKey(req, res); if (!objectKey) return; try { diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 74394326..b3810f50 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -148,7 +148,7 @@ export function buildLocalConfigurationStatus( backup: { provider: cloudProvider, autoUpload: cloudAutoUpload, - locked: !isAdmiral, + locked: false, requiredTier: 'admiral', }, }; diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index f39954e9..7cd422ad 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -114,11 +114,11 @@ The Automation block only renders on Skipper or Admiral. | Row | What it shows | |-----|---------------| -| **Cloud Backup** (Admiral) | The active cloud backup target: `Sencho Cloud`, `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` | +| **Cloud Backup** | The active cloud backup target: `Sencho Cloud` (Admiral only), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` | | **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%` | | **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise | -Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered at all; you do not see a locked placeholder. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible, so a Community node sees a tighter card with no Automation block and no Cloud Backup row. +Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered at all; you do not see a locked placeholder. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible. The data refreshes automatically every 60 seconds and immediately on any container start/stop/restart event broadcast over the live notification stream, so the card stays in lockstep with what the rest of the UI shows. diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index d52d6c56..d9738dbc 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -38,7 +38,7 @@ The snapshot list shows each snapshot in a table with the following columns: - **Description** - your optional label, or a prefix like "Scheduled snapshot" for automated ones. If Cloud Backup is configured, an upload icon in this column marks snapshots that have been mirrored off-site. - **Scope** - how many nodes and stacks were captured (e.g. "3 nodes, 21 stacks") - **Warnings** - a warning icon with a count if any nodes were skipped, or "None" -- **Actions** - **View** to open the detail view, a cloud-upload icon for snapshots not yet mirrored (Admiral only), and a delete button for admins +- **Actions** - **View** to open the detail view, a cloud-upload icon for snapshots not yet mirrored to a configured Cloud Backup target, and a delete button for admins Snapshot list showing paginated rows with date, description with cloud upload indicator, scope, warnings, and action buttons @@ -87,7 +87,7 @@ Admins can delete snapshots from the list view by clicking the trash icon on the ## Cloud Backup - Cloud Backup requires an Admiral license. Configure it in **Settings → System → Cloud Backup**. + Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → System → Cloud Backup**. Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → System → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported. @@ -146,7 +146,7 @@ For in-place rollback, use the **Restore** action on the snapshot detail view as | Delete cloud snapshot | Yes | No | No | No | No | - Cloud backup actions (upload, delete cloud snapshots) also require an Admiral license. + Cloud backup actions to Sencho Cloud Backup require an Admiral license. Cloud backup actions to a Custom S3-compatible target work on every tier. ## Storage diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 3b38823c..0b5bb625 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -30,6 +30,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Git sources for compose stacks - Multi-node management in both Proxy and Pilot Agent modes - Manual fleet snapshots (create, browse, restore, delete) and per-node Sencho updates +- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket) - Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison - CVE suppressions - Alert rules with Discord, Slack, and webhook targets diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index d1e842fb..5bc02a1c 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -257,7 +257,7 @@ See [Private Registries](/features/private-registries) for the full walkthrough. ## Cloud Backup - Cloud Backup requires a Sencho Admiral license. + Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. **Scope:** Global, admin-only diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index 1601b592..3e053d80 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -67,6 +67,12 @@ export default function FleetSnapshots() { const { license, isPaid } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; + // Cloud-upload affordance is reachable when the saved provider is custom + // (every tier) or sencho on an Admiral license. A downgraded admin whose + // saved provider is still 'sencho' sees no upload button — they cannot + // call POST /cloud-backup/upload/:id because gateForCurrentProvider would + // 403 anyway, so the UI must not advertise an action that is gated away. + const [cloudEnabled, setCloudEnabled] = useState(false); const [snapshots, setSnapshots] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); @@ -114,8 +120,19 @@ export default function FleetSnapshots() { fetchSnapshots(); }, [fetchSnapshots]); + const fetchCloudConfig = useCallback(async () => { + try { + const res = await apiFetch('/cloud-backup/config', { localOnly: true }); + if (!res.ok) return; + const data = await res.json() as { provider: 'disabled' | 'sencho' | 'custom' }; + setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isAdmiral)); + } catch { + // best-effort; cloud affordances stay hidden on failure + } + }, [isAdmiral]); + const fetchCloudSnapshots = useCallback(async () => { - if (!isAdmiral) return; + if (!cloudEnabled) return; try { const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true }); if (!res.ok) return; @@ -124,7 +141,11 @@ export default function FleetSnapshots() { } catch { // best-effort; cloud indicators stay hidden on failure } - }, [isAdmiral]); + }, [cloudEnabled]); + + useEffect(() => { + fetchCloudConfig(); + }, [fetchCloudConfig]); useEffect(() => { fetchCloudSnapshots(); @@ -611,7 +632,7 @@ export default function FleetSnapshots() { View - {isAdmin && isAdmiral && !cloudSnapshotIds.has(snapshot.id) && ( + {isAdmin && cloudEnabled && !cloudSnapshotIds.has(snapshot.id) && ( + + + + + {usage && ( +
+
+ Storage used + + {formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects) + +
+
+
+
+
+ )} + +
+ +

+ Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds. +

+
+
+ )} + + {provider === 'custom' && ( +
+
- Activate Sencho Cloud Backup + Custom S3 Configuration +
+
+ + + {saving ? : null} + Save +
-

- Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license. -

- - {provisioning ? : } - Activate -
- )} - {provider === 'sencho' && senchoProvisioned && ( -
-
-
- - Sencho Cloud Backup -
-
- - -
+
+
+ + setCustom({ ...custom, endpoint: e.target.value })} + />
+
+ + setCustom({ ...custom, region: e.target.value })} + /> +
+
+ + setCustom({ ...custom, bucket: e.target.value })} + /> +
+
+ + setCustom({ ...custom, path_prefix: e.target.value })} + /> +
+
+ + setCustom({ ...custom, access_key: e.target.value })} + /> +
+
+ + setCustom({ ...custom, secret_key: e.target.value })} + /> +
+
- {usage && ( -
-
- Storage used - - {formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects) +
+
+ +

Automatically upload every fleet snapshot to this bucket.

+
+ +
+
+ )} + + {provider !== 'disabled' && ( +
+
+ Cloud Snapshots +
+ {needsPagination && ( + <> + + + {safePage + 1} / {totalPages} -
-
-
-
-
- )} - -
- -

- Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds. -

+ + + )} +
- )} - - {provider === 'custom' && ( -
-
-
- - Custom S3 Configuration -
-
- - - {saving ? : null} - Save - -
+ {snapshots.length === 0 ? ( +
+ + No cloud snapshots yet. The next fleet snapshot will appear here.
- -
-
- - setCustom({ ...custom, endpoint: e.target.value })} - /> -
-
- - setCustom({ ...custom, region: e.target.value })} - /> -
-
- - setCustom({ ...custom, bucket: e.target.value })} - /> -
-
- - setCustom({ ...custom, path_prefix: e.target.value })} - /> -
-
- - setCustom({ ...custom, access_key: e.target.value })} - /> -
-
- - setCustom({ ...custom, secret_key: e.target.value })} - /> -
-
- -
-
- -

Automatically upload every fleet snapshot to this bucket.

-
- -
-
- )} - - {provider !== 'disabled' && ( -
-
- Cloud Snapshots -
- {needsPagination && ( - <> - - - {safePage + 1} / {totalPages} - - - - )} - -
-
- {snapshots.length === 0 ? ( -
- - No cloud snapshots yet. The next fleet snapshot will appear here. -
- ) : ( -
    - {pagedSnapshots.map(s => ( -
  • -
    -
    {s.objectKey.split('/').pop()}
    -
    - {formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''} -
    + ) : ( +
      + {pagedSnapshots.map(s => ( +
    • +
      +
      {s.objectKey.split('/').pop()}
      +
      + {formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''}
      -
      -
      +
      + - -
      -
    • - ))} -
    - )} -
    - )} + const blob = await res.blob(); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz'; + link.click(); + URL.revokeObjectURL(link.href); + } catch (err) { + toast.error((err as Error)?.message || 'Download failed.'); + } + }} + title="Download" + > + + + +
+ + ))} + + )} +
+ )} - !open && setDeleteKey(null)} - variant="destructive" - kicker="CLOUD · DELETE · IRREVERSIBLE" - title="Delete cloud snapshot" - confirmLabel="Delete" - onConfirm={confirmDelete} - > -

- Permanently removes the archive from your bucket. The local SQLite copy is unaffected. -

-
-
- + !open && setDeleteKey(null)} + variant="destructive" + kicker="CLOUD · DELETE · IRREVERSIBLE" + title="Delete cloud snapshot" + confirmLabel="Delete" + onConfirm={confirmDelete} + > +

+ Permanently removes the archive from your bucket. The local SQLite copy is unaffected. +

+
+
); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 2b748324..8c2fe6cc 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -120,7 +120,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Cloud Backup', description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.', keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'], - tier: 'admiral', + tier: null, scope: 'global', adminOnly: true, hiddenOnRemote: true,