diff --git a/CHANGELOG.md b/CHANGELOG.md index 26dc2ed..c9e64b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/src/components/BackupsTab.test.ts b/src/components/BackupsTab.test.ts index 9dd2f09..c7fb178 100644 --- a/src/components/BackupsTab.test.ts +++ b/src/components/BackupsTab.test.ts @@ -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" } }, }), @@ -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 } = {}) => { @@ -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 () => { @@ -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"); }); }); @@ -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", + ); }); }); @@ -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"); }); }); diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index a37bfd3..2b5e25e 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -2,12 +2,14 @@

Backups

-
- - @@ -55,6 +57,7 @@
- + +
@@ -83,14 +90,14 @@ Next: {{ formatDate(task.next_run) }}
-
@@ -198,9 +205,16 @@ 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(); @@ -208,6 +222,7 @@ const backups = ref([]); const loadingBackups = ref(false); const creatingBackup = ref(false); const restoringBackup = ref(null); +const downloadingBackup = ref(null); interface TrackedJob extends BackupJob { retryCount?: number; @@ -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") { @@ -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) { @@ -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, @@ -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 () => { diff --git a/src/components/DeploymentHealthCheckModal.test.ts b/src/components/DeploymentHealthCheckModal.test.ts index 323fdaa..4359a5f 100644 --- a/src/components/DeploymentHealthCheckModal.test.ts +++ b/src/components/DeploymentHealthCheckModal.test.ts @@ -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(); @@ -11,7 +11,7 @@ vi.mock("@/services/api", async (importOriginal) => { }; }); -const metadata = { +const metadata: ServiceMetadata = { name: "postgres", type: "infrastructure", primary_service: "postgres", @@ -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: { @@ -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: "", + }, + ], }); }); @@ -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); + }); }); diff --git a/src/components/DeploymentHealthCheckModal.vue b/src/components/DeploymentHealthCheckModal.vue index 5564603..b1fdddc 100644 --- a/src/components/DeploymentHealthCheckModal.vue +++ b/src/components/DeploymentHealthCheckModal.vue @@ -10,6 +10,23 @@ @close="emit('close')" >
+
+
+ + + Remove + +
+
@@ -72,6 +89,16 @@ Save and check + diff --git a/src/composables/useDeploymentJob.ts b/src/composables/useDeploymentJob.ts index b58eb54..eea2f10 100644 --- a/src/composables/useDeploymentJob.ts +++ b/src/composables/useDeploymentJob.ts @@ -134,6 +134,10 @@ export function useDeploymentJob(onSettled?: (state: DeploymentJobState) => void } function openStream(jobId: string) { + if (new URLSearchParams(window.location.search).has("server")) { + pollUntilDone(jobId); + return; + } let authed = false; const token = localStorage.getItem("auth_token"); diff --git a/src/composables/useServiceJobs.ts b/src/composables/useServiceJobs.ts index 728e59b..dda70b9 100644 --- a/src/composables/useServiceJobs.ts +++ b/src/composables/useServiceJobs.ts @@ -88,6 +88,10 @@ export function useServiceJobs(getDeployment: () => string, onSettled?: (s: Serv const name = getDeployment(); const c = controllers[service]; if (!c) return; + if (new URLSearchParams(window.location.search).has("server")) { + pollUntilDone(service, jobId); + return; + } let authed = false; const token = localStorage.getItem("auth_token"); diff --git a/src/services/api.test.ts b/src/services/api.test.ts index 3bf7380..eb7aa9c 100644 --- a/src/services/api.test.ts +++ b/src/services/api.test.ts @@ -16,12 +16,12 @@ const unauthorized = (config: Parameters[0]) => describe("api client session gate", () => { const originalAdapter = apiClient.defaults.adapter; - let location: { pathname: string; href: string }; + let location: { pathname: string; href: string; search: string }; beforeEach(() => { resetSessionGate(); localStorage.clear(); - location = { pathname: "/", href: "/" }; + location = { pathname: "/", href: "/", search: "" }; Object.defineProperty(window, "location", { value: location, writable: true }); }); @@ -112,4 +112,30 @@ describe("api client session gate", () => { expect(adapter).toHaveBeenCalledTimes(6); expect(results.every((r) => r.status === "rejected")).toBe(true); }); + + it("routes deployment detail requests through the selected peer", async () => { + localStorage.setItem("auth_token", "good"); + location.pathname = "/deployments/test-app"; + location.search = "?server=prod-2"; + const adapter = vi.fn(ok); + apiClient.defaults.adapter = adapter as AxiosAdapter; + + await apiClient.get("/deployments/test-app/compose"); + + expect(adapter).toHaveBeenCalledWith( + expect.objectContaining({ url: "/cluster/peers/prod-2/proxy/deployments/test-app/compose" }), + ); + }); + + it("keeps session requests on the selected server", async () => { + localStorage.setItem("auth_token", "good"); + location.pathname = "/deployments/test-app"; + location.search = "?server=prod-2"; + const adapter = vi.fn(ok); + apiClient.defaults.adapter = adapter as AxiosAdapter; + + await apiClient.get("/users/me"); + + expect(adapter).toHaveBeenCalledWith(expect.objectContaining({ url: "/users/me" })); + }); }); diff --git a/src/services/api.ts b/src/services/api.ts index 3cd084b..45a02fb 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -41,6 +41,22 @@ const isSessionAgnostic = (url: string) => sessionAgnosticPaths.some((p) => url. const isUngated = (url: string) => isPublic(url) || isSessionAgnostic(url); const onAuthPage = () => window.location.pathname.includes("/login") || window.location.pathname.includes("/setup"); +const isPeerDeploymentPath = (url: string, deployment: string) => { + const encodedName = encodeURIComponent(deployment); + return ( + url.startsWith(`/deployments/${encodedName}`) || + url.startsWith(`/deployments/${deployment}`) || + url.startsWith(`/containers/`) || + url.startsWith(`/scheduler/`) || + url.startsWith(`/proxy/status/${encodedName}`) || + url.startsWith(`/proxy/status/${deployment}`) || + url.startsWith(`/proxy/setup/${encodedName}`) || + url.startsWith(`/proxy/setup/${deployment}`) || + url.startsWith(`/proxy/${encodedName}`) || + url.startsWith(`/proxy/${deployment}`) + ); +}; + // A page load fans out into a dozen calls at once. Firing them all against a token the agent // has already stopped accepting spends a rejection on each, which the agent counts as a run of // authentication failures and blocks the address for. So the first call goes alone and the rest @@ -85,11 +101,20 @@ export const resetSessionGate = () => { }; apiClient.interceptors.request.use(async (config) => { - const url = config.url || ""; + let url = config.url || ""; if (isPublic(url)) { return config; } + const routeMatch = window.location.pathname.match(/^\/deployments\/([^/]+)/); + const peer = new URLSearchParams(window.location.search).get("server"); + const deployment = routeMatch ? decodeURIComponent(routeMatch[1]) : ""; + if (peer && deployment && isPeerDeploymentPath(url, deployment)) { + url = `/cluster/peers/${encodeURIComponent(peer)}/proxy${url}`; + config.url = url; + config.headers.set("X-FlatRun-Deployment", deployment); + } + const token = localStorage.getItem("auth_token"); if (!token) { localStorage.removeItem("auth_token"); @@ -189,6 +214,7 @@ export interface ServiceMetadata { response_contains?: string; command?: string; }; + healthchecks?: Array; protected_mode?: ProtectedModeConfig; require_plan?: boolean; credential_id?: string; @@ -1749,12 +1775,28 @@ export const backupsApi = { create: (deploymentName: string) => apiClient.post<{ job_id: string; message: string }>("/backups", { deployment_name: deploymentName }), - delete: (id: string) => apiClient.delete<{ message: string }>(`/backups/${id}`), + delete: (id: string, deploymentName?: string) => + apiClient.delete<{ message: string }>( + deploymentName ? `/deployments/${deploymentName}/backups/${id}` : `/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), + restore: ( + id: string, + options?: { restore_data?: boolean; restore_db?: boolean; stop_first?: boolean }, + deploymentName?: string, + ) => + apiClient.post<{ job_id: string; message: string }>( + deploymentName ? `/deployments/${deploymentName}/backups/${id}/restore` : `/backups/${id}/restore`, + options, + ), - download: (id: string) => `${apiClient.defaults.baseURL}/backups/${id}/download`, + download: (id: string, deploymentName?: string) => + apiClient.get( + deploymentName ? `/deployments/${deploymentName}/backups/${id}/download` : `/backups/${id}/download`, + { + responseType: "blob", + }, + ), getDeploymentBackups: (name: string, limit?: number) => apiClient.get<{ backups: Backup[] }>(`/deployments/${name}/backups`, { @@ -1770,7 +1812,10 @@ export const backupsApi = { 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}`), + getJob: (jobId: string, deploymentName?: string) => + apiClient.get<{ job: BackupJob }>( + deploymentName ? `/deployments/${deploymentName}/backups/jobs/${jobId}` : `/backups/jobs/${jobId}`, + ), listJobs: (deployment?: string, limit?: number) => apiClient.get<{ jobs: BackupJob[] }>("/backups/jobs", { @@ -2048,6 +2093,7 @@ export type ClusterCapability = | "fleet.read" | "deployments.read" | "deployments.run" + | "deployments.manage" | "capacity.read" | "capacity.offer" | "events.publish" @@ -2151,13 +2197,6 @@ export const clusterApi = { apiClient.post("/cluster/accept", { invite_token: inviteToken, peer_url: peerUrl }), removePeer: (name: string) => apiClient.delete<{ status: string; peer: string }>(`/cluster/peers/${name}`), getAggregatedDeployments: () => apiClient.get("/cluster/deployments"), - getDeployment: (server: string, name: string) => - apiClient.get<{ - deployment: Deployment; - compose_content?: string; - compose_filename?: string; - proxy_status?: unknown; - }>(`/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}`), deploymentAction: (server: string, name: string, action: "start" | "stop" | "restart") => apiClient.post( `/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}/${action}`, diff --git a/src/types/index.ts b/src/types/index.ts index e9b552c..7436ce5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,6 +27,7 @@ export interface ServiceMetadata { networking: NetworkingConfig; ssl: SSLConfig; healthcheck: HealthCheckConfig; + healthchecks?: HealthCheckConfig[]; quick_actions?: QuickAction[]; security?: DeploymentSecurityConfig; protected_mode?: ProtectedModeConfig; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index cc468aa..c672415 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -42,7 +42,6 @@ const adminPermissions: string[] = [ "scheduler:write", "scheduler:delete", "system:read", - "system:write", "system:files", "dns:read", "dns:write", diff --git a/src/views/CertificatesView.vue b/src/views/CertificatesView.vue index a874b2b..2fd9b3c 100755 --- a/src/views/CertificatesView.vue +++ b/src/views/CertificatesView.vue @@ -16,11 +16,11 @@ loading-text="Loading certificates..." >