From 99af837882d5fbe5b3f65689565325e51a84159e Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 3 Jan 2026 13:41:55 +0100 Subject: [PATCH 1/2] feat(backup): Add BackupsTab component with async job polling - Create BackupsTab.vue component for backup management - Add backup/restore with async job polling (2s interval) - Add scheduled backup creation UI with cron expression - Add BackupJob types and API methods to api.ts - Update DeploymentDetailView to use BackupsTab component - Include restore, download, delete functionality --- src/components/BackupsTab.vue | 887 +++++++++++++++++++++++++++++ src/services/api.ts | 195 +++++++ src/views/DeploymentDetailView.vue | 170 +++++- 3 files changed, 1249 insertions(+), 3 deletions(-) create mode 100644 src/components/BackupsTab.vue diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue new file mode 100644 index 0000000..c515b1f --- /dev/null +++ b/src/components/BackupsTab.vue @@ -0,0 +1,887 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index 9919230..fcacf34 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -759,3 +759,198 @@ export const trafficApi = { params: since ? { since } : undefined, }), }; + +// Backup Types +export interface Backup { + id: string; + deployment_name: string; + status: "pending" | "in_progress" | "completed" | "failed"; + size: number; + path: string; + components: string[]; + error?: string; + created_at: string; + completed_at?: string; + expires_at?: string; +} + +export interface BackupSpec { + container_paths?: ContainerBackupPath[]; + databases?: DatabaseBackupSpec[]; + pre_hooks?: BackupHookSpec[]; + post_hooks?: BackupHookSpec[]; + exclude_patterns?: string[]; +} + +export interface ContainerBackupPath { + service: string; + container_path: string; + description?: string; + required: boolean; +} + +export interface DatabaseBackupSpec { + service: string; + type: string; + host_env?: string; + port_env?: string; + user_env?: string; + password_env?: string; + database_env?: string; + host?: string; + port?: number; + user?: string; + database?: string; +} + +export interface BackupHookSpec { + service: string; + command: string; + timeout?: number; +} + +export type BackupJobType = "backup" | "restore"; +export type BackupJobStatus = "pending" | "running" | "completed" | "failed"; + +export interface BackupJob { + id: string; + type: BackupJobType; + status: BackupJobStatus; + deployment_name: string; + backup_id?: string; + progress?: string; + error?: string; + started_at: string; + completed_at?: string; +} + +export const backupsApi = { + list: (deployment?: string, limit?: number) => + apiClient.get<{ backups: Backup[] }>("/backups", { + params: { deployment, limit }, + }), + + get: (id: string) => apiClient.get<{ backup: Backup }>(`/backups/${id}`), + + create: (deploymentName: string) => + apiClient.post<{ job_id: string; message: string }>("/backups", { deployment_name: deploymentName }), + + delete: (id: string) => apiClient.delete<{ message: string }>(`/backups/${id}`), + + restore: (id: string, options?: { restore_data?: boolean; restore_db?: boolean; stop_first?: boolean }) => + apiClient.post<{ job_id: string; message: string }>(`/backups/${id}/restore`, options), + + download: (id: string) => `/api/backups/${id}/download`, + + getDeploymentBackups: (name: string, limit?: number) => + apiClient.get<{ backups: Backup[] }>(`/deployments/${name}/backups`, { + params: limit ? { limit } : undefined, + }), + + createDeploymentBackup: (name: string) => + apiClient.post<{ job_id: string; message: string }>(`/deployments/${name}/backups`), + + getDeploymentBackupConfig: (name: string) => + apiClient.get<{ backup_config: BackupSpec | null }>(`/deployments/${name}/backup-config`), + + updateDeploymentBackupConfig: (name: string, config: BackupSpec) => + apiClient.put<{ backup_config: BackupSpec }>(`/deployments/${name}/backup-config`, config), + + getJob: (jobId: string) => + apiClient.get<{ job: BackupJob }>(`/backups/jobs/${jobId}`), + + listJobs: (deployment?: string, limit?: number) => + apiClient.get<{ jobs: BackupJob[] }>("/backups/jobs", { + params: { deployment, limit }, + }), +}; + +// Scheduler Types +export type TaskType = "backup" | "command"; +export type TaskStatus = "pending" | "running" | "completed" | "failed"; + +export interface ScheduledTask { + id: number; + name: string; + type: TaskType; + deployment_name: string; + cron_expr: string; + enabled: boolean; + config: TaskConfig; + last_run?: string; + next_run?: string; + created_at: string; + updated_at: string; +} + +export interface TaskConfig { + backup_config?: BackupTaskConfig; + command_config?: CommandTaskConfig; +} + +export interface BackupTaskConfig { + retention_count: number; + storage_path?: string; +} + +export interface CommandTaskConfig { + service: string; + command: string; + timeout: number; +} + +export interface TaskExecution { + id: number; + task_id: number; + status: TaskStatus; + output?: string; + error?: string; + started_at: string; + ended_at?: string; + duration_ms?: number; +} + +export interface CreateTaskRequest { + name: string; + type: TaskType; + deployment_name: string; + cron_expr: string; + enabled: boolean; + config: TaskConfig; +} + +export interface UpdateTaskRequest { + name?: string; + cron_expr?: string; + enabled?: boolean; + config?: TaskConfig; +} + +export const schedulerApi = { + listTasks: (deployment?: string) => + apiClient.get<{ tasks: ScheduledTask[] }>("/scheduler/tasks", { + params: deployment ? { deployment } : undefined, + }), + + getTask: (id: number) => apiClient.get<{ task: ScheduledTask }>(`/scheduler/tasks/${id}`), + + createTask: (data: CreateTaskRequest) => + apiClient.post<{ task: ScheduledTask }>("/scheduler/tasks", data), + + updateTask: (id: number, data: UpdateTaskRequest) => + apiClient.put<{ task: ScheduledTask }>(`/scheduler/tasks/${id}`, data), + + deleteTask: (id: number) => apiClient.delete<{ message: string }>(`/scheduler/tasks/${id}`), + + runTaskNow: (id: number) => apiClient.post<{ message: string }>(`/scheduler/tasks/${id}/run`), + + getTaskExecutions: (taskId: number, limit?: number) => + apiClient.get<{ executions: TaskExecution[] }>(`/scheduler/tasks/${taskId}/executions`, { + params: limit ? { limit } : undefined, + }), + + getRecentExecutions: (limit?: number) => + apiClient.get<{ executions: TaskExecution[] }>("/scheduler/executions", { + params: limit ? { limit } : undefined, + }), +}; diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 5d6fb58..514dd5f 100644 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -498,6 +498,11 @@ + +
@@ -1250,6 +1255,7 @@ import FileBrowser from "@/components/FileBrowser.vue"; import LogViewer from "@/components/LogViewer.vue"; import ConfirmModal from "@/components/ConfirmModal.vue"; import ContainerTerminal from "@/components/ContainerTerminal.vue"; +import BackupsTab from "@/components/BackupsTab.vue"; const route = useRoute(); const router = useRouter(); @@ -1298,6 +1304,7 @@ const tabs = [ { id: "terminal", label: "Terminal", icon: "pi pi-desktop" }, { id: "environment", label: "Environment", icon: "pi pi-list" }, { id: "actions", label: "Quick Actions", icon: "pi pi-bolt" }, + { id: "backups", label: "Backups", icon: "pi pi-history" }, { id: "security", label: "Security", icon: "pi pi-shield" }, { id: "config", label: "Configuration", icon: "pi pi-cog" }, ]; @@ -1310,6 +1317,9 @@ const resourceUsage = ref({ network: 0, }); +const showDeleteEnvModal = ref(false); +const envKeyToDelete = ref(""); + const logs = ref(""); const logsLoading = ref(false); const logsService = ref("all"); @@ -1372,9 +1382,6 @@ const actionForm = ref({ icon: "pi pi-play", service: "", }); -const showDeleteEnvModal = ref(false); -const envKeyToDelete = ref(""); - const showDomainSettingsModal = ref(false); const savingDomainSettings = ref(false); const domainSettings = ref({ @@ -4249,4 +4256,161 @@ onUnmounted(() => { display: none; } } + +/* Backups Tab */ +.backups-tab { + padding: 1.5rem; +} + +.backups-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.backups-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.backups-actions { + display: flex; + gap: 0.5rem; +} + +.backups-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.backup-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--surface-card); + border: 1px solid var(--surface-border); + border-radius: var(--radius); +} + +.backup-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.backup-name { + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.backup-meta { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.backup-status { + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; +} + +.backup-status.completed { + background: #d1fae5; + color: #065f46; +} + +.backup-status.in_progress { + background: #dbeafe; + color: #1e40af; +} + +.backup-status.failed { + background: #fee2e2; + color: #991b1b; +} + +.backup-components { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.component-badge { + padding: 0.125rem 0.375rem; + background: var(--surface-hover); + border-radius: var(--radius-sm); + font-size: 0.625rem; + text-transform: uppercase; +} + +.scheduled-backups-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--surface-border); +} + +.scheduled-backups-section h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; +} + +.scheduled-tasks-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.scheduled-task-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--surface-card); + border: 1px solid var(--surface-border); + border-radius: var(--radius); +} + +.task-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.task-name { + font-weight: 500; +} + +.task-schedule { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--surface-hover); + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); +} + +.task-next { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.task-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.toggle-switch.small { + transform: scale(0.8); +} From 704106742c5e46e8b0fb69e4c44d7135328b70cb Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 3 Jan 2026 13:57:48 +0100 Subject: [PATCH 2/2] test(backup): Add unit tests for BackupsTab component Add comprehensive tests for: - Component structure and rendering - Backup list display and actions - Create, delete, and restore functionality - Schedule modal and scheduled tasks - Job polling behavior Signed-off-by: nfebe --- src/components/BackupsTab.test.ts | 492 +++++++++++++++++++++++++ src/components/BackupsTab.vue | 65 ++-- src/services/api.ts | 144 ++++---- src/views/DeploymentDetailView.test.ts | 11 +- src/views/DeploymentDetailView.vue | 162 +------- 5 files changed, 602 insertions(+), 272 deletions(-) create mode 100644 src/components/BackupsTab.test.ts diff --git a/src/components/BackupsTab.test.ts b/src/components/BackupsTab.test.ts new file mode 100644 index 0000000..9dd2f09 --- /dev/null +++ b/src/components/BackupsTab.test.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import BackupsTab from "./BackupsTab.vue"; +import { backupsApi, schedulerApi } from "@/services/api"; + +vi.mock("@/services/api", () => ({ + backupsApi: { + getDeploymentBackups: vi.fn().mockResolvedValue({ data: { backups: [] } }), + createDeploymentBackup: vi.fn().mockResolvedValue({ data: { job_id: "job-123" } }), + delete: vi.fn().mockResolvedValue({ data: { success: true } }), + restore: vi.fn().mockResolvedValue({ data: { job_id: "restore-job-123" } }), + download: vi.fn().mockReturnValue("/api/backups/test-backup/download"), + getJob: vi.fn().mockResolvedValue({ + data: { job: { id: "job-123", status: "completed", type: "backup" } }, + }), + }, + schedulerApi: { + listTasks: vi.fn().mockResolvedValue({ data: { tasks: [] } }), + createTask: vi.fn().mockResolvedValue({ data: { task: { id: 1 } } }), + updateTask: vi.fn().mockResolvedValue({ data: { success: true } }), + deleteTask: vi.fn().mockResolvedValue({ data: { success: true } }), + runTaskNow: vi.fn().mockResolvedValue({ data: { success: true } }), + }, +})); + +const mockGetDeploymentBackups = backupsApi.getDeploymentBackups as ReturnType; +const mockCreateDeploymentBackup = backupsApi.createDeploymentBackup as ReturnType; +const mockDeleteBackup = backupsApi.delete as ReturnType; +const mockRestoreBackup = backupsApi.restore as ReturnType; +const mockListTasks = schedulerApi.listTasks as ReturnType; +const mockCreateTask = schedulerApi.createTask as ReturnType; + +const mockBackups = [ + { + id: "my-app_20250101_120000", + deployment_name: "my-app", + status: "completed", + size: 1048576, + components: ["compose", "env"], + created_at: "2025-01-01T12:00:00Z", + path: "/backups/my-app/my-app_20250101_120000.tar.gz", + }, + { + id: "my-app_20250102_120000", + deployment_name: "my-app", + status: "completed", + size: 2097152, + components: ["compose", "env", "data"], + created_at: "2025-01-02T12:00:00Z", + path: "/backups/my-app/my-app_20250102_120000.tar.gz", + }, +]; + +const mockScheduledTasks = [ + { + id: 1, + name: "Daily backup", + type: "backup", + deployment_name: "my-app", + cron_expr: "0 2 * * *", + enabled: true, + next_run: "2025-01-03T02:00:00Z", + }, +]; + +describe("BackupsTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetDeploymentBackups.mockResolvedValue({ data: { backups: [] } }); + mockListTasks.mockResolvedValue({ data: { tasks: [] } }); + }); + + const mountBackupsTab = (options: { backups?: typeof mockBackups; tasks?: typeof mockScheduledTasks } = {}) => { + if (options.backups) { + mockGetDeploymentBackups.mockResolvedValue({ data: { backups: options.backups } }); + } + if (options.tasks) { + mockListTasks.mockResolvedValue({ data: { tasks: options.tasks } }); + } + + return mount(BackupsTab, { + props: { + deploymentName: "my-app", + }, + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + }), + ], + stubs: { + Teleport: true, + ConfirmModal: true, + }, + }, + }); + }; + + describe("Component structure", () => { + it("renders the backups tab container", () => { + const wrapper = mountBackupsTab(); + expect(wrapper.find(".backups-tab").exists()).toBe(true); + }); + + it("renders the header with title", () => { + const wrapper = mountBackupsTab(); + expect(wrapper.find(".backups-header").exists()).toBe(true); + expect(wrapper.find(".backups-header h3").text()).toBe("Backups"); + }); + + it("renders Create Backup button", () => { + const wrapper = mountBackupsTab(); + expect(wrapper.find(".btn-primary").exists()).toBe(true); + expect(wrapper.text()).toContain("Create Backup"); + }); + + it("renders Schedule Backup button", () => { + const wrapper = mountBackupsTab(); + const scheduleBtn = wrapper.findAll(".btn-secondary").find((btn) => btn.text().includes("Schedule")); + expect(scheduleBtn).toBeDefined(); + }); + }); + + describe("Empty state", () => { + it("shows empty state when no backups", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".empty-state").exists()).toBe(true); + expect(wrapper.text()).toContain("No backups yet"); + }); + + it("shows helpful message in empty state", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Create your first backup"); + }); + }); + + describe("Loading state", () => { + it("shows loading state when loadingBackups is true", async () => { + const wrapper = mount(BackupsTab, { + props: { + deploymentName: "my-app", + }, + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + }), + ], + stubs: { + Teleport: true, + ConfirmModal: true, + }, + }, + }); + + const vm = wrapper.vm as any; + vm.loadingBackups = true; + vm.backups = []; + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".loading-state").exists()).toBe(true); + expect(wrapper.text()).toContain("Loading backups"); + }); + }); + + describe("Backups list", () => { + it("renders backup items when backups exist", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const backupItems = wrapper.findAll(".backup-item"); + expect(backupItems.length).toBe(2); + }); + + it("displays backup ID", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("my-app_20250101_120000"); + }); + + it("displays backup status", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const statusBadge = wrapper.find(".backup-status"); + expect(statusBadge.exists()).toBe(true); + expect(statusBadge.text()).toBe("completed"); + }); + + it("displays component badges", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const componentBadges = wrapper.findAll(".component-badge"); + expect(componentBadges.length).toBeGreaterThan(0); + }); + + it("has Restore button for each backup", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const restoreButtons = wrapper + .findAll(".backup-actions .btn-secondary") + .filter((btn) => btn.text().includes("Restore")); + expect(restoreButtons.length).toBe(2); + }); + + it("has Download link for each backup", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const downloadLinks = wrapper.findAll(".backup-actions a[download]"); + expect(downloadLinks.length).toBe(2); + }); + + it("has Delete button for each backup", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const deleteButtons = wrapper.findAll(".backup-actions .btn-danger"); + expect(deleteButtons.length).toBe(2); + }); + }); + + describe("Create backup", () => { + it("calls API when Create Backup button is clicked", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + + const createBtn = wrapper.find(".btn-primary"); + await createBtn.trigger("click"); + + expect(mockCreateDeploymentBackup).toHaveBeenCalledWith("my-app"); + }); + + it("disables button while creating backup", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + + const createBtn = wrapper.find(".btn-primary"); + await createBtn.trigger("click"); + + expect(createBtn.attributes("disabled")).toBeDefined(); + }); + }); + + describe("Delete backup", () => { + it("opens confirm modal when delete is clicked", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const deleteBtn = wrapper.find(".backup-actions .btn-danger"); + await deleteBtn.trigger("click"); + + const vm = wrapper.vm as any; + expect(vm.showDeleteBackupModal).toBe(true); + expect(vm.backupToDelete).toBe("my-app_20250101_120000"); + }); + + it("calls delete API on confirm", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const vm = wrapper.vm as any; + vm.backupToDelete = "my-app_20250101_120000"; + vm.showDeleteBackupModal = true; + + await vm.deleteBackup(); + + expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000"); + }); + }); + + describe("Restore backup", () => { + it("opens confirm modal when restore is clicked", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const restoreBtn = wrapper + .findAll(".backup-actions .btn-secondary") + .find((btn) => btn.text().includes("Restore")); + await restoreBtn?.trigger("click"); + + const vm = wrapper.vm as any; + expect(vm.showRestoreModal).toBe(true); + expect(vm.backupToRestore).toEqual(mockBackups[0]); + }); + + it("calls restore API on confirm", async () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const vm = wrapper.vm as any; + vm.backupToRestore = mockBackups[0]; + vm.showRestoreModal = true; + + await vm.restoreBackup(); + + expect(mockRestoreBackup).toHaveBeenCalledWith("my-app_20250101_120000", { + restore_data: true, + restore_db: true, + stop_first: true, + }); + }); + }); + + describe("Schedule modal", () => { + it("opens schedule modal when button is clicked", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + + const scheduleBtn = wrapper.findAll(".btn-secondary").find((btn) => btn.text().includes("Schedule")); + await scheduleBtn?.trigger("click"); + + const vm = wrapper.vm as any; + expect(vm.showScheduleModal).toBe(true); + }); + + it("has default form values", async () => { + const wrapper = mountBackupsTab(); + const vm = wrapper.vm as any; + + expect(vm.scheduleForm.cronExpr).toBe("0 2 * * *"); + expect(vm.scheduleForm.retentionCount).toBe(7); + expect(vm.scheduleForm.enabled).toBe(true); + }); + + it("calls API to create scheduled task", async () => { + const wrapper = mountBackupsTab(); + const vm = wrapper.vm as any; + + vm.scheduleForm = { + name: "Daily backup", + cronExpr: "0 3 * * *", + retentionCount: 5, + enabled: true, + }; + + await vm.createScheduledTask(); + + expect(mockCreateTask).toHaveBeenCalledWith({ + name: "Daily backup", + type: "backup", + deployment_name: "my-app", + cron_expr: "0 3 * * *", + enabled: true, + config: { + backup_config: { + retention_count: 5, + }, + }, + }); + }); + }); + + describe("Scheduled tasks list", () => { + it("shows scheduled tasks section when tasks exist", async () => { + const wrapper = mountBackupsTab({ tasks: mockScheduledTasks }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".scheduled-backups-section").exists()).toBe(true); + expect(wrapper.text()).toContain("Scheduled Backups"); + }); + + it("displays task name and cron expression", async () => { + const wrapper = mountBackupsTab({ tasks: mockScheduledTasks }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Daily backup"); + expect(wrapper.text()).toContain("0 2 * * *"); + }); + + it("has toggle switch for enabling/disabling task", async () => { + const wrapper = mountBackupsTab({ tasks: mockScheduledTasks }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const toggleSwitch = wrapper.find(".toggle-switch input[type='checkbox']"); + expect(toggleSwitch.exists()).toBe(true); + }); + + it("has run now button for each task", async () => { + const wrapper = mountBackupsTab({ tasks: mockScheduledTasks }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const runButtons = wrapper.findAll(".task-actions .btn-secondary"); + expect(runButtons.length).toBeGreaterThan(0); + }); + + it("has delete button for each task", async () => { + const wrapper = mountBackupsTab({ tasks: mockScheduledTasks }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + await wrapper.vm.$nextTick(); + + const deleteBtn = wrapper.find(".task-actions .btn-danger"); + expect(deleteBtn.exists()).toBe(true); + }); + }); + + describe("Utility functions", () => { + it("formatBytes formats bytes correctly", () => { + const wrapper = mountBackupsTab({ backups: mockBackups }); + const vm = wrapper.vm as any; + + expect(vm.formatBytes(0)).toBe("0 B"); + expect(vm.formatBytes(1024)).toBe("1 KB"); + expect(vm.formatBytes(1048576)).toBe("1 MB"); + expect(vm.formatBytes(1073741824)).toBe("1 GB"); + }); + + it("formatDate formats date correctly", () => { + const wrapper = mountBackupsTab(); + const vm = wrapper.vm as any; + + const result = vm.formatDate("2025-01-01T12:00:00Z"); + expect(result).toBeTruthy(); + }); + + it("getDownloadUrl returns correct URL", () => { + const wrapper = mountBackupsTab(); + const vm = wrapper.vm as any; + + vm.getDownloadUrl("test-backup-id"); + expect(backupsApi.download).toHaveBeenCalledWith("test-backup-id"); + }); + }); + + describe("Job polling", () => { + it("starts polling when backup job is created", async () => { + const wrapper = mountBackupsTab(); + await wrapper.vm.$nextTick(); + + const vm = wrapper.vm as any; + const createBtn = wrapper.find(".btn-primary"); + await createBtn.trigger("click"); + await wrapper.vm.$nextTick(); + + expect(vm.activeJobs.length).toBe(1); + expect(vm.activeJobs[0].id).toBe("job-123"); + }); + + it("stops polling when no active jobs", async () => { + const wrapper = mountBackupsTab(); + const vm = wrapper.vm as any; + + vm.activeJobs = []; + await vm.pollActiveJobs(); + + expect(vm.activeJobs.length).toBe(0); + }); + }); +}); diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index c515b1f..cd21c2e 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -3,11 +3,7 @@

Backups

- @@ -56,11 +52,7 @@ Restore - + Download @@ -78,17 +70,11 @@
{{ task.name }} {{ task.cron_expr }} - - Next: {{ formatDate(task.next_run) }} - + Next: {{ formatDate(task.next_run) }}