Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/BackupsTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock("@/services/api", () => ({
getJob: vi.fn().mockResolvedValue({
data: { job: { id: "job-123", status: "completed", type: "backup" } },
}),
retryPublication: vi.fn().mockResolvedValue({ data: { backup: { status: "completed" } } }),
},
schedulerApi: {
listTasks: vi.fn().mockResolvedValue({ data: { tasks: [] } }),
Expand All @@ -28,6 +29,7 @@ const mockGetDeploymentBackups = backupsApi.getDeploymentBackups as ReturnType<t
const mockCreateDeploymentBackup = backupsApi.createDeploymentBackup as ReturnType<typeof vi.fn>;
const mockDeleteBackup = backupsApi.delete as ReturnType<typeof vi.fn>;
const mockRestoreBackup = backupsApi.restore as ReturnType<typeof vi.fn>;
const mockRetryPublication = backupsApi.retryPublication as ReturnType<typeof vi.fn>;
const mockListTasks = schedulerApi.listTasks as ReturnType<typeof vi.fn>;
const mockCreateTask = schedulerApi.createTask as ReturnType<typeof vi.fn>;

Expand Down Expand Up @@ -178,6 +180,37 @@ describe("BackupsTab", () => {
});

describe("Backups list", () => {
it("shows incomplete protection and retries publication", async () => {
const incomplete = [
{
...mockBackups[0],
status: "local_only",
destination_results: [{ name: "archive", status: "failed", error: "upload failed" }],
},
];
const wrapper = mountBackupsTab({ backups: incomplete as typeof mockBackups });
await new Promise((resolve) => setTimeout(resolve, 10));
const retry = wrapper.findAll("button").find((button) => button.text().includes("Retry publication"));
expect(retry).toBeTruthy();
expect(wrapper.text()).toContain("archive: failed");
await retry!.trigger("click");
expect(mockRetryPublication).toHaveBeenCalledWith("my-app", mockBackups[0].id);
});

it("does not offer publication retry for cleanup-only failures", async () => {
const cleanupFailure = [
{
...mockBackups[0],
status: "partial",
cleanup_results: [{ name: "post_hooks", kind: "cleanup", required: true, status: "failed" }],
destination_results: [{ name: "archive", status: "completed" }],
},
];
const wrapper = mountBackupsTab({ backups: cleanupFailure as typeof mockBackups });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(wrapper.text()).not.toContain("Retry publication");
});

it("renders backup items when backups exist", async () => {
const wrapper = mountBackupsTab({ backups: mockBackups });
await wrapper.vm.$nextTick();
Expand Down
96 changes: 93 additions & 3 deletions src/components/BackupsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,36 @@
{{ comp }}
</span>
</div>
<div v-if="failedResults(backup).length" class="backup-failures">
<div v-for="result in failedResults(backup)" :key="`${result.kind}:${result.name}`">
<strong>{{ result.name }}</strong
>: {{ result.error || "Failed" }}
</div>
</div>
<div v-if="backup.destination_results?.length" class="destination-results">
<span
v-for="destination in backup.destination_results"
:key="destination.name"
class="destination-result"
:class="destination.status"
:title="destination.error"
>
{{ destination.name }}: {{ destination.status }}
</span>
</div>
</div>
<div class="backup-actions">
<button
v-if="canWrite"
v-if="canWrite && hasFailedDestination(backup)"
class="btn btn-sm btn-secondary"
:disabled="retryingPublication === backup.id"
@click="retryPublication(backup.id)"
>
<i :class="retryingPublication === backup.id ? 'pi pi-spin pi-spinner' : 'pi pi-cloud-upload'" />
Retry publication
</button>
<button
v-if="canWrite && backup.status !== 'failed'"
class="btn btn-sm btn-secondary"
:disabled="restoringBackup === backup.id"
@click="confirmRestore(backup)"
Expand All @@ -66,6 +92,7 @@
Restore
</button>
<button
v-if="backup.status !== 'failed'"
class="btn btn-sm btn-secondary"
:disabled="downloadingBackup === backup.id"
@click="downloadBackup(backup.id)"
Expand Down Expand Up @@ -223,6 +250,33 @@ const loadingBackups = ref(false);
const creatingBackup = ref(false);
const restoringBackup = ref<string | null>(null);
const downloadingBackup = ref<string | null>(null);
const retryingPublication = ref<string | null>(null);

const failedResults = (backup: Backup) =>
[...(backup.component_results || []), ...(backup.cleanup_results || [])].filter(
(result) => result.status === "failed",
);

const hasFailedDestination = (backup: Backup) =>
backup.destination_results?.some((result) => result.status === "failed") ?? false;

const retryPublication = async (backupId: string) => {
retryingPublication.value = backupId;
try {
const response = await backupsApi.retryPublication(props.deploymentName, backupId);
const status = response.data.backup.status;
if (status === "completed") {
notifications.success("Publication Complete", "Backup was published to every destination");
} else {
notifications.error("Publication Incomplete", "One or more destinations remain unavailable");
}
await fetchBackups();
} catch (err: any) {
notifications.error("Publication Failed", err.response?.data?.error || "Failed to publish backup");
} finally {
retryingPublication.value = null;
}
};

interface TrackedJob extends BackupJob {
retryCount?: number;
Expand Down Expand Up @@ -299,9 +353,13 @@ const pollActiveJobs = async () => {
const response = await backupsApi.getJob(job.id, props.deploymentName);
const updatedJob = response.data.job;

if (updatedJob.status === "completed") {
if (["completed", "partial", "local_only"].includes(updatedJob.status)) {
if (updatedJob.type === "backup") {
notifications.success("Backup Complete", "Backup has been created successfully");
if (updatedJob.status === "completed") {
notifications.success("Backup Complete", "Backup has been created successfully");
} else {
notifications.error("Backup Protection Incomplete", updatedJob.progress || "Backup is available locally");
}
creatingBackup.value = false;
} else {
notifications.success("Restore Complete", "Backup has been restored successfully");
Expand Down Expand Up @@ -628,6 +686,38 @@ onUnmounted(() => {
color: var(--color-danger-700);
}

.backup-status.partial,
.backup-status.local_only {
background: var(--color-warning-50);
color: var(--color-warning-700);
}

.backup-failures {
margin-top: 0.5rem;
color: var(--color-danger-700);
font-size: 0.8125rem;
}

.destination-results {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}

.destination-result {
border-radius: 999px;
padding: 0.125rem 0.5rem;
background: var(--color-success-50);
color: var(--color-success-700);
font-size: 0.75rem;
}

.destination-result.failed {
background: var(--color-danger-50);
color: var(--color-danger-700);
}

.backup-location {
display: inline-flex;
align-items: center;
Expand Down
27 changes: 25 additions & 2 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1707,7 +1707,7 @@ export const trafficApi = {
export interface Backup {
readonly id: string;
readonly deployment_name: string;
readonly status: "pending" | "in_progress" | "completed" | "failed";
readonly status: "pending" | "in_progress" | "completed" | "partial" | "local_only" | "failed";
readonly size: number;
readonly path: string;
readonly components: readonly string[];
Expand All @@ -1716,6 +1716,23 @@ export interface Backup {
readonly completed_at?: string;
readonly expires_at?: string;
readonly locations?: readonly string[];
readonly component_results?: readonly BackupComponentResult[];
readonly cleanup_results?: readonly BackupComponentResult[];
readonly destination_results?: readonly BackupDestinationResult[];
}

export interface BackupComponentResult {
readonly name: string;
readonly kind: string;
readonly required: boolean;
readonly status: "completed" | "skipped" | "failed";
readonly error?: string;
}

export interface BackupDestinationResult {
readonly name: string;
readonly status: "completed" | "skipped" | "failed";
readonly error?: string;
}

export interface BackupSpec {
Expand Down Expand Up @@ -1754,7 +1771,7 @@ export interface BackupHookSpec {
}

export type BackupJobType = "backup" | "restore";
export type BackupJobStatus = "pending" | "running" | "completed" | "failed";
export type BackupJobStatus = "pending" | "running" | "completed" | "partial" | "local_only" | "failed";

export interface BackupJob {
readonly id: string;
Expand All @@ -1766,6 +1783,9 @@ export interface BackupJob {
readonly error?: string;
readonly started_at: string;
readonly completed_at?: string;
readonly component_results?: readonly BackupComponentResult[];
readonly cleanup_results?: readonly BackupComponentResult[];
readonly destination_results?: readonly BackupDestinationResult[];
}

export const backupsApi = {
Expand Down Expand Up @@ -1810,6 +1830,9 @@ export const backupsApi = {
createDeploymentBackup: (name: string) =>
apiClient.post<{ job_id: string; message: string }>(`/deployments/${name}/backups`),

retryPublication: (name: string, id: string) =>
apiClient.post<{ backup: Backup }>(`/deployments/${name}/backups/${id}/retry-publication`),

getDeploymentBackupConfig: (name: string) =>
apiClient.get<{ backup_config: BackupSpec | null }>(`/deployments/${name}/backup-config`),

Expand Down
Loading