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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- Deployment autoscaling configuration with workload compatibility and activation guidance
- Grouped notification incidents with editable targets and delivery rules
- HTTP, TCP, and container command health checks for web services and databases
- Full deployment details and management controls for permitted Fleet peers
- Service-specific HTTP, TCP, and command health check editing

### Changed
- Deployments show the selected server in the navigation and remain local by default
Expand All @@ -16,9 +18,13 @@
- Assigned local deployments load without Fleet access or Fleet availability
- Peer deployment lists reload when switching from an empty local server
- Peer deployment details open for users with read access
- Peer deployment details use the same overview, configuration, files, and actions as local deployments
- Object storage and notifications follow their dedicated permissions
- Operators no longer see Updates unless access is explicitly granted
- Read-only Settings access is clearly identified and its controls remain legible in dark mode
- User deployment grants can target a deployment on a specific Fleet server
- Host terminal and process controls are hidden unless system write access is explicitly granted
- Global certificate actions remain administrator-only while deployment certificates stay manageable

## [0.4.0-beta.4] - 2026-08-21

Expand Down
45 changes: 30 additions & 15 deletions src/components/BackupsTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ vi.mock("@/services/api", () => ({
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"),
download: vi.fn().mockResolvedValue({ data: new Blob(["backup"]) }),
getJob: vi.fn().mockResolvedValue({
data: { job: { id: "job-123", status: "completed", type: "backup" } },
}),
Expand Down Expand Up @@ -69,6 +69,11 @@ describe("BackupsTab", () => {
vi.clearAllMocks();
mockGetDeploymentBackups.mockResolvedValue({ data: { backups: [] } });
mockListTasks.mockResolvedValue({ data: { tasks: [] } });
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn().mockReturnValue("blob:backup"),
revokeObjectURL: vi.fn(),
});
});

const mountBackupsTab = (options: { backups?: typeof mockBackups; tasks?: typeof mockScheduledTasks } = {}) => {
Expand Down Expand Up @@ -225,14 +230,16 @@ describe("BackupsTab", () => {
expect(restoreButtons.length).toBe(2);
});

it("has Download link for each backup", async () => {
it("has a Download 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 downloadLinks = wrapper.findAll(".backup-actions a[download]");
expect(downloadLinks.length).toBe(2);
const downloadButtons = wrapper
.findAll(".backup-actions button")
.filter((button) => button.text().includes("Download"));
expect(downloadButtons.length).toBe(2);
});

it("has Delete button for each backup", async () => {
Expand Down Expand Up @@ -295,7 +302,7 @@ describe("BackupsTab", () => {

await vm.deleteBackup();

expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000");
expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000", "my-app");
});
});

Expand Down Expand Up @@ -328,11 +335,15 @@ describe("BackupsTab", () => {

await vm.restoreBackup();

expect(mockRestoreBackup).toHaveBeenCalledWith("my-app_20250101_120000", {
restore_data: true,
restore_db: true,
stop_first: true,
});
expect(mockRestoreBackup).toHaveBeenCalledWith(
"my-app_20250101_120000",
{
restore_data: true,
restore_db: true,
stop_first: true,
},
"my-app",
);
});
});

Expand Down Expand Up @@ -456,12 +467,16 @@ describe("BackupsTab", () => {
expect(result).toBeTruthy();
});

it("getDownloadUrl returns correct URL", () => {
const wrapper = mountBackupsTab();
const vm = wrapper.vm as any;
it("downloads through the authenticated API client", async () => {
const wrapper = mountBackupsTab({ backups: mockBackups });
await new Promise((resolve) => setTimeout(resolve, 10));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);

const download = wrapper.findAll("button").find((button) => button.text().includes("Download"));
await download!.trigger("click");

vm.getDownloadUrl("test-backup-id");
expect(backupsApi.download).toHaveBeenCalledWith("test-backup-id");
expect(click).toHaveBeenCalled();
expect(backupsApi.download).toHaveBeenCalledWith("my-app_20250101_120000", "my-app");
});
});

Expand Down
80 changes: 56 additions & 24 deletions src/components/BackupsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
<div class="backups-tab">
<div class="backups-header">
<h3>Backups</h3>
<div class="backups-actions">
<button class="btn btn-primary" :disabled="creatingBackup" @click="createBackup">
<i :class="creatingBackup ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
{{ creatingBackup ? "Creating..." : "Create Backup" }}
</button>
<button class="btn btn-secondary" @click="showScheduleModal = true">
<div v-if="canWrite || canSchedule" class="backups-actions">
<template v-if="canWrite">
<button class="btn btn-primary" :disabled="creatingBackup" @click="createBackup">
<i :class="creatingBackup ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
{{ creatingBackup ? "Creating..." : "Create Backup" }}
</button>
</template>
<button v-if="canSchedule" class="btn btn-secondary" @click="showScheduleModal = true">
<i class="pi pi-clock" />
Schedule Backup
</button>
Expand Down Expand Up @@ -55,18 +57,23 @@
</div>
<div class="backup-actions">
<button
v-if="canWrite"
class="btn btn-sm btn-secondary"
:disabled="restoringBackup === backup.id"
@click="confirmRestore(backup)"
>
<i :class="restoringBackup === backup.id ? 'pi pi-spin pi-spinner' : 'pi pi-replay'" />
Restore
</button>
<a :href="getDownloadUrl(backup.id)" class="btn btn-sm btn-secondary" download>
<button
class="btn btn-sm btn-secondary"
:disabled="downloadingBackup === backup.id"
@click="downloadBackup(backup.id)"
>
<i class="pi pi-download" />
Download
</a>
<button class="btn btn-sm btn-danger" @click="confirmDeleteBackup(backup.id)">
</button>
<button v-if="canDelete" class="btn btn-sm btn-danger" @click="confirmDeleteBackup(backup.id)">
<i class="pi pi-trash" />
</button>
</div>
Expand All @@ -83,14 +90,14 @@
<span v-if="task.next_run" class="task-next"> Next: {{ formatDate(task.next_run) }} </span>
</div>
<div class="task-actions">
<label class="toggle-switch small">
<label v-if="canSchedule" class="toggle-switch small">
<input type="checkbox" :checked="task.enabled" @change="toggleTask(task)" />
<span class="toggle-slider" />
</label>
<button class="btn btn-sm btn-secondary" @click="runTaskNow(task.id)">
<button v-if="canSchedule" class="btn btn-sm btn-secondary" @click="runTaskNow(task.id)">
<i class="pi pi-play" />
</button>
<button class="btn btn-sm btn-danger" @click="confirmDeleteTask(task.id)">
<button v-if="canDeleteSchedule" class="btn btn-sm btn-danger" @click="confirmDeleteTask(task.id)">
<i class="pi pi-trash" />
</button>
</div>
Expand Down Expand Up @@ -198,16 +205,24 @@ import type { Backup, ScheduledTask, BackupJob } from "@/services/api";
import { useNotificationsStore } from "@/stores/notifications";
import ConfirmModal from "@/components/ConfirmModal.vue";

const props = defineProps<{
deploymentName: string;
}>();
const props = withDefaults(
defineProps<{
deploymentName: string;
canWrite?: boolean;
canDelete?: boolean;
canSchedule?: boolean;
canDeleteSchedule?: boolean;
}>(),
{ canWrite: true, canDelete: true, canSchedule: true, canDeleteSchedule: true },
);

const notifications = useNotificationsStore();

const backups = ref<Backup[]>([]);
const loadingBackups = ref(false);
const creatingBackup = ref(false);
const restoringBackup = ref<string | null>(null);
const downloadingBackup = ref<string | null>(null);

interface TrackedJob extends BackupJob {
retryCount?: number;
Expand Down Expand Up @@ -281,7 +296,7 @@ const pollActiveJobs = async () => {
const updatedJobs: TrackedJob[] = [];
for (const job of activeJobs.value) {
try {
const response = await backupsApi.getJob(job.id);
const response = await backupsApi.getJob(job.id, props.deploymentName);
const updatedJob = response.data.job;

if (updatedJob.status === "completed") {
Expand Down Expand Up @@ -354,7 +369,7 @@ const confirmDeleteBackup = (backupId: string) => {
const deleteBackup = async () => {
if (!backupToDelete.value) return;
try {
await backupsApi.delete(backupToDelete.value);
await backupsApi.delete(backupToDelete.value, props.deploymentName);
notifications.success("Deleted", "Backup has been deleted");
await fetchBackups();
} catch (err: any) {
Expand All @@ -377,11 +392,15 @@ const restoreBackup = async () => {
restoringBackup.value = backupId;
showRestoreModal.value = false;
try {
const response = await backupsApi.restore(backupId, {
restore_data: true,
restore_db: true,
stop_first: true,
});
const response = await backupsApi.restore(
backupId,
{
restore_data: true,
restore_db: true,
stop_first: true,
},
props.deploymentName,
);
const jobId = response.data.job_id;
activeJobs.value.push({
id: jobId,
Expand All @@ -401,8 +420,21 @@ const restoreBackup = async () => {
}
};

const getDownloadUrl = (backupId: string) => {
return backupsApi.download(backupId);
const downloadBackup = async (backupId: string) => {
downloadingBackup.value = backupId;
try {
const response = await backupsApi.download(backupId, props.deploymentName);
const url = URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.download = `${backupId}.tar.gz`;
link.click();
URL.revokeObjectURL(url);
} catch (err: any) {
notifications.error("Download Failed", err.response?.data?.error || "Failed to download backup");
} finally {
downloadingBackup.value = null;
}
};

const createScheduledTask = async () => {
Expand Down
85 changes: 65 additions & 20 deletions src/components/DeploymentHealthCheckModal.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import DeploymentHealthCheckModal from "./DeploymentHealthCheckModal.vue";
import { deploymentsApi } from "@/services/api";
import { deploymentsApi, type ServiceMetadata } from "@/services/api";

vi.mock("@/services/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/api")>();
Expand All @@ -11,7 +11,7 @@ vi.mock("@/services/api", async (importOriginal) => {
};
});

const metadata = {
const metadata: ServiceMetadata = {
name: "postgres",
type: "infrastructure",
primary_service: "postgres",
Expand All @@ -20,10 +20,10 @@ const metadata = {
healthcheck: { path: "", interval: "30s" },
};

const mountModal = async (theme: "light" | "dark" = "light") => {
const mountModal = async (theme: "light" | "dark" = "light", selectedMetadata = metadata) => {
document.documentElement.dataset.theme = theme;
const wrapper = mount(DeploymentHealthCheckModal, {
props: { visible: false, deploymentName: "postgres", services: ["postgres"], metadata },
props: { visible: false, deploymentName: "postgres", services: ["postgres"], metadata: selectedMetadata },
global: {
stubs: {
BaseModal: {
Expand Down Expand Up @@ -60,16 +60,19 @@ describe("DeploymentHealthCheckModal", () => {
await flushPromises();

expect(deploymentsApi.updateMetadata).toHaveBeenCalledWith("postgres", {
healthcheck: {
type: "tcp",
service: "postgres",
port: 5432,
path: "",
interval: "30s",
success_statuses: [],
response_contains: "",
command: "",
},
healthcheck: { path: "", interval: "" },
healthchecks: [
{
type: "tcp",
service: "postgres",
port: 5432,
path: "",
interval: "30s",
success_statuses: [],
response_contains: "",
command: "",
},
],
});
});

Expand All @@ -86,13 +89,55 @@ describe("DeploymentHealthCheckModal", () => {
expect(deploymentsApi.updateMetadata).toHaveBeenLastCalledWith(
"postgres",
expect.objectContaining({
healthcheck: expect.objectContaining({
type: "exec",
service: "postgres",
port: 0,
command: "pg_isready -U postgres",
}),
healthchecks: [
expect.objectContaining({
type: "exec",
service: "postgres",
port: 0,
command: "pg_isready -U postgres",
}),
],
}),
);
});

it("preserves the selected service interval when editing", async () => {
const wrapper = await mountModal("light", {
...metadata,
healthchecks: [{ type: "tcp" as const, service: "postgres", port: 5432, path: "", interval: "5s" }],
});

await wrapper
.findAll("button")
.find((button) => button.text().includes("Save and check"))!
.trigger("click");
await flushPromises();

expect(deploymentsApi.updateMetadata).toHaveBeenLastCalledWith(
"postgres",
expect.objectContaining({ healthchecks: [expect.objectContaining({ service: "postgres", interval: "5s" })] }),
);
});

it("confirms removal and refreshes the parent after success", async () => {
const wrapper = await mountModal("light", {
...metadata,
healthchecks: [{ type: "tcp" as const, service: "postgres", port: 5432, path: "", interval: "5s" }],
});
await wrapper
.findAll("button")
.find((button) => button.text() === "Remove")!
.trigger("click");
await flushPromises();

const confirm = document.body.querySelector(".confirm-modal .btn-warning") as HTMLButtonElement;
confirm.click();
await flushPromises();

expect(deploymentsApi.updateMetadata).toHaveBeenLastCalledWith("postgres", {
healthcheck: { path: "", interval: "" },
healthchecks: [],
});
expect(wrapper.emitted("saved")).toHaveLength(1);
});
});
Loading
Loading