From 6c8939deb8153920b8e1d035411272c49c972a13 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 27 Sep 2026 11:04:04 +0100 Subject: [PATCH 1/6] ui(backups): Select deployment destinations Application backup settings now choose the remote stores that receive future recovery points while preserving the existing all-destinations default. --- src/components/BackupsTab.test.ts | 32 ++++++++++ src/components/BackupsTab.vue | 99 ++++++++++++++++++++++++++++++- src/services/api.ts | 9 +++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/components/BackupsTab.test.ts b/src/components/BackupsTab.test.ts index a6c5249..e587fd3 100644 --- a/src/components/BackupsTab.test.ts +++ b/src/components/BackupsTab.test.ts @@ -15,6 +15,9 @@ vi.mock("@/services/api", () => ({ data: { job: { id: "job-123", status: "completed", type: "backup" } }, }), retryPublication: vi.fn().mockResolvedValue({ data: { backup: { status: "completed" } } }), + getDeploymentBackupDestinations: vi.fn().mockResolvedValue({ data: { destinations: [] } }), + getDeploymentBackupConfig: vi.fn().mockResolvedValue({ data: { backup_config: {} } }), + updateDeploymentBackupConfig: vi.fn().mockResolvedValue({ data: { backup_config: {} } }), }, schedulerApi: { listTasks: vi.fn().mockResolvedValue({ data: { tasks: [] } }), @@ -30,6 +33,9 @@ const mockCreateDeploymentBackup = backupsApi.createDeploymentBackup as ReturnTy const mockDeleteBackup = backupsApi.delete as ReturnType; const mockRestoreBackup = backupsApi.restore as ReturnType; const mockRetryPublication = backupsApi.retryPublication as ReturnType; +const mockGetBackupDestinations = backupsApi.getDeploymentBackupDestinations as ReturnType; +const mockGetBackupConfig = backupsApi.getDeploymentBackupConfig as ReturnType; +const mockUpdateBackupConfig = backupsApi.updateDeploymentBackupConfig as ReturnType; const mockListTasks = schedulerApi.listTasks as ReturnType; const mockCreateTask = schedulerApi.createTask as ReturnType; @@ -71,6 +77,8 @@ describe("BackupsTab", () => { vi.clearAllMocks(); mockGetDeploymentBackups.mockResolvedValue({ data: { backups: [] } }); mockListTasks.mockResolvedValue({ data: { tasks: [] } }); + mockGetBackupDestinations.mockResolvedValue({ data: { destinations: [] } }); + mockGetBackupConfig.mockResolvedValue({ data: { backup_config: {} } }); vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:backup"), @@ -105,6 +113,30 @@ describe("BackupsTab", () => { }; describe("Component structure", () => { + it("saves selected destinations for this deployment", async () => { + mockGetBackupDestinations.mockResolvedValue({ + data: { + destinations: [ + { name: "primary", kind: "external" }, + { name: "archive", kind: "managed" }, + ], + }, + }); + mockGetBackupConfig.mockResolvedValue({ data: { backup_config: { destinations: ["primary"] } } }); + mockUpdateBackupConfig.mockResolvedValue({ data: { backup_config: { destinations: ["primary", "archive"] } } }); + const wrapper = mountBackupsTab(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const archive = wrapper.findAll(".destination-option").find((option) => option.text().includes("archive")); + await archive!.find("input").setValue(true); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Save destinations"))! + .trigger("click"); + + expect(mockUpdateBackupConfig).toHaveBeenCalledWith("my-app", { destinations: ["primary", "archive"] }); + }); + it("renders the backups tab container", () => { const wrapper = mountBackupsTab(); expect(wrapper.find(".backups-tab").exists()).toBe(true); diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index bcb70e3..0f98c9d 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -16,6 +16,33 @@ + +

Choose every remote store that should receive this application's backups.

+

No enabled remote destinations are available.

+
+ +
+ +
+
Loading backups... @@ -228,9 +255,11 @@ + + diff --git a/src/services/api.ts b/src/services/api.ts index e9a4b6a..66906e0 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1765,6 +1765,41 @@ export interface DeploymentBackupPolicy { readonly cleanup_preview: BackupCleanupPreview; } +export interface MigrationSite { + hostname: string; + source_path?: string; + bytes?: number; + transferred: boolean; + last_synced_at?: string; + resolved?: readonly string[]; + dns_propagated: boolean; +} + +export interface MigrationPlan { + source: string; + sites: MigrationSite[]; + inventory_complete: boolean; + initial_transfer_at?: string; + last_sync_at?: string; + cutover_at?: string; + expected_address?: string; + notes?: string; +} + +export interface MigrationStatus { + plan: MigrationPlan | null; + retirement_ready: boolean; + blockers: readonly string[]; +} + +export const migrationsApi = { + get: (name: string) => apiClient.get<{ migration: MigrationStatus }>(`/deployments/${name}/migration`), + update: (name: string, plan: MigrationPlan) => + apiClient.put<{ migration: MigrationStatus }>(`/deployments/${name}/migration`, plan), + checkDns: (name: string) => + apiClient.post<{ migration: MigrationStatus }>(`/deployments/${name}/migration/check-dns`), +}; + export interface BackupDestinationOption { readonly name: string; readonly kind: string; diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts index 9b892b1..3ac4f82 100644 --- a/src/views/DeploymentDetailView.test.ts +++ b/src/views/DeploymentDetailView.test.ts @@ -213,11 +213,11 @@ describe("DeploymentDetailView", () => { }); describe("Tab navigation", () => { - it("displays all ten tabs", async () => { + it("displays all eleven tabs", async () => { const wrapper = mountView(); await flushPromises(); const tabs = wrapper.findAll(".tab-btn"); - expect(tabs.length).toBe(10); + expect(tabs.length).toBe(11); }); it("has Overview tab", async () => { @@ -341,6 +341,7 @@ describe("DeploymentDetailView", () => { { id: "databases", label: "Databases", icon: "pi pi-database" }, { id: "actions", label: "Quick Actions", icon: "pi pi-bolt" }, { id: "backups", label: "Backups", icon: "pi pi-history" }, + { id: "migration", label: "Migration", icon: "pi pi-directions" }, { id: "security", label: "Security", icon: "pi pi-shield" }, { id: "config", label: "Configuration", icon: "pi pi-cog" }, ]); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 735ecfd..b306882 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -784,6 +784,7 @@ :can-schedule="canManageSchedules" :can-delete-schedule="canDeleteSchedules" /> +
Date: Sun, 27 Sep 2026 18:23:56 +0100 Subject: [PATCH 6/6] fix(backups): Match cleanup to its preview Cleanup now uses the saved retention value shown in the preview. Unsaved policy edits cannot delete more backups than the preview reports. --- src/components/BackupsTab.test.ts | 40 +++++++++++++++++++++++++++++++ src/components/BackupsTab.vue | 6 ++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/components/BackupsTab.test.ts b/src/components/BackupsTab.test.ts index ea79cce..a04f23e 100644 --- a/src/components/BackupsTab.test.ts +++ b/src/components/BackupsTab.test.ts @@ -50,6 +50,8 @@ const mockRetryPublication = backupsApi.retryPublication as ReturnType; const mockGetBackupConfig = backupsApi.getDeploymentBackupConfig as ReturnType; const mockUpdateBackupConfig = backupsApi.updateDeploymentBackupConfig as ReturnType; +const mockGetBackupPolicy = backupsApi.getDeploymentBackupPolicy as ReturnType; +const mockCleanupBackups = backupsApi.cleanupDeploymentBackups as ReturnType; const mockListTasks = schedulerApi.listTasks as ReturnType; const mockCreateTask = schedulerApi.createTask as ReturnType; @@ -93,6 +95,19 @@ describe("BackupsTab", () => { mockListTasks.mockResolvedValue({ data: { tasks: [] } }); mockGetBackupDestinations.mockResolvedValue({ data: { destinations: [] } }); mockGetBackupConfig.mockResolvedValue({ data: { backup_config: {} } }); + mockGetBackupPolicy.mockResolvedValue({ + data: { + policy: { + config: {}, + schedules: [], + backup_count: 0, + local_bytes: 0, + failed_count: 0, + size_alert: false, + cleanup_preview: { keep_count: 7, delete_ids: [], reclaimed_bytes: 0 }, + }, + }, + }); vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:backup"), @@ -156,6 +171,31 @@ describe("BackupsTab", () => { }); }); + it("applies the retention value shown in the cleanup preview", async () => { + mockGetBackupPolicy.mockResolvedValue({ + data: { + policy: { + config: { retention_count: 7 }, + schedules: [], + backup_count: 9, + local_bytes: 0, + failed_count: 0, + size_alert: false, + cleanup_preview: { keep_count: 7, delete_ids: ["old"], reclaimed_bytes: 1 }, + }, + }, + }); + const wrapper = mountBackupsTab(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await wrapper.find('input[type="number"]').setValue(1); + await wrapper + .findAll("button") + .find((button) => button.text().includes("Apply cleanup"))! + .trigger("click"); + + expect(mockCleanupBackups).toHaveBeenCalledWith("my-app", 7); + }); + it("renders the backups tab container", () => { const wrapper = mountBackupsTab(); expect(wrapper.find(".backups-tab").exists()).toBe(true); diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index c6835d1..9898f68 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -406,7 +406,11 @@ const savePolicy = async () => { const applyCleanup = async () => { try { - const response = await backupsApi.cleanupDeploymentBackups(props.deploymentName, Number(retentionCount.value)); + if (!policy.value) return; + const response = await backupsApi.cleanupDeploymentBackups( + props.deploymentName, + policy.value.cleanup_preview.keep_count, + ); notifications.success("Cleanup Complete", `${response.data.deleted} local backups removed`); await Promise.all([fetchBackups(), fetchBackupDestinations()]); } catch (err: any) {