From a034266519624c7d32f5159dccd14e85c6889726 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 23 Aug 2026 14:18:27 +0100 Subject: [PATCH 1/2] ui: Add full peer deployment management Peer deployments now use the standard detail experience with scoped controls. Service health checks support separate protocols without exposing host administration. --- CHANGELOG.md | 6 + src/components/BackupsTab.test.ts | 18 +- src/components/BackupsTab.vue | 20 +- .../DeploymentHealthCheckModal.test.ts | 37 +-- src/components/DeploymentHealthCheckModal.vue | 153 ++++++++++-- src/components/RemoteDeploymentOverview.vue | 228 ------------------ src/composables/useDeploymentJob.ts | 4 + src/composables/useServiceJobs.ts | 4 + src/services/api.test.ts | 30 ++- src/services/api.ts | 62 ++++- src/types/index.ts | 1 + src/utils/permissions.ts | 1 - src/views/CertificatesView.vue | 5 +- src/views/ClusterView.vue | 17 +- src/views/DeploymentDetailView.test.ts | 27 +-- src/views/DeploymentDetailView.vue | 78 +++--- src/views/UsersView.vue | 19 +- 17 files changed, 344 insertions(+), 366 deletions(-) delete mode 100644 src/components/RemoteDeploymentOverview.vue 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..89e4acf 100644 --- a/src/components/BackupsTab.test.ts +++ b/src/components/BackupsTab.test.ts @@ -295,7 +295,7 @@ describe("BackupsTab", () => { await vm.deleteBackup(); - expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000"); + expect(mockDeleteBackup).toHaveBeenCalledWith("my-app_20250101_120000", "my-app"); }); }); @@ -328,11 +328,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", + ); }); }); @@ -461,7 +465,7 @@ describe("BackupsTab", () => { const vm = wrapper.vm as any; vm.getDownloadUrl("test-backup-id"); - expect(backupsApi.download).toHaveBeenCalledWith("test-backup-id"); + expect(backupsApi.download).toHaveBeenCalledWith("test-backup-id", "my-app"); }); }); diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index a37bfd3..805b96d 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -281,7 +281,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 +354,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 +377,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, @@ -402,7 +406,7 @@ const restoreBackup = async () => { }; const getDownloadUrl = (backupId: string) => { - return backupsApi.download(backupId); + return backupsApi.download(backupId, props.deploymentName); }; const createScheduledTask = async () => { diff --git a/src/components/DeploymentHealthCheckModal.test.ts b/src/components/DeploymentHealthCheckModal.test.ts index 323fdaa..aaa94c4 100644 --- a/src/components/DeploymentHealthCheckModal.test.ts +++ b/src/components/DeploymentHealthCheckModal.test.ts @@ -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,12 +89,14 @@ 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", + }), + ], }), ); }); diff --git a/src/components/DeploymentHealthCheckModal.vue b/src/components/DeploymentHealthCheckModal.vue index 5564603..7946bdf 100644 --- a/src/components/DeploymentHealthCheckModal.vue +++ b/src/components/DeploymentHealthCheckModal.vue @@ -10,6 +10,23 @@ @close="emit('close')" >
+
+
+ + + Remove + +
+
@@ -84,6 +101,8 @@ import BaseTextarea from "@/components/base/BaseTextarea.vue"; import BaseButton from "@/components/base/BaseButton.vue"; import { deploymentsApi, type ServiceMetadata } from "@/services/api"; +type HealthCheck = ServiceMetadata["healthcheck"]; + const props = defineProps<{ visible: boolean; deploymentName: string; @@ -102,27 +121,57 @@ const command = ref(""); const saving = ref(false); const error = ref(""); +const configuredChecks = ref([]); + +function legacyChecks(): HealthCheck[] { + if (props.metadata?.healthchecks?.length) return props.metadata.healthchecks.map((check) => ({ ...check })); + const legacy = props.metadata?.healthcheck; + if (legacy && (legacy.type || legacy.path || legacy.command || legacy.port)) return [{ ...legacy }]; + return []; +} + +function checkLabel(check: HealthCheck) { + if (check.type === "exec") return "Container command"; + if (check.type === "tcp") return `TCP port ${check.port}`; + return `HTTP ${check.path || "/"}`; +} + +function loadCheck(name: string) { + const configured = configuredChecks.value.find((check) => check.service === name); + const check = configured || ({} as HealthCheck); + checkType.value = check.type === "tcp" || check.type === "exec" ? check.type : "http"; + service.value = name; + const fallbackPort = name === props.metadata?.networking?.service ? props.metadata.networking.container_port : 0; + port.value = check.port ? String(check.port) : fallbackPort ? String(fallbackPort) : ""; + path.value = check.path || ""; + statuses.value = check.success_statuses?.join(", ") || ""; + responseContains.value = check.response_contains || ""; + command.value = check.command || ""; +} + +function selectCheck(name: string) { + loadCheck(name); +} + watch( () => props.visible, (open) => { if (!open) return; - const configuredType = props.metadata?.healthcheck?.type; - checkType.value = configuredType === "tcp" || configuredType === "exec" ? configuredType : "http"; - service.value = - props.metadata?.healthcheck?.service || + configuredChecks.value = legacyChecks(); + const initialService = + configuredChecks.value[0]?.service || props.metadata?.primary_service || props.metadata?.networking?.service || ""; - const configuredPort = props.metadata?.healthcheck?.port || props.metadata?.networking?.container_port; - port.value = configuredPort ? String(configuredPort) : ""; - path.value = props.metadata?.healthcheck?.path || ""; - statuses.value = props.metadata?.healthcheck?.success_statuses?.join(", ") || ""; - responseContains.value = props.metadata?.healthcheck?.response_contains || ""; - command.value = props.metadata?.healthcheck?.command || ""; + loadCheck(initialService); error.value = ""; }, ); +watch(service, (name) => { + if (name) loadCheck(name); +}); + function parsedStatuses(): number[] | null { if (!statuses.value.trim()) return []; const values = statuses.value.split(",").map((value) => Number(value.trim())); @@ -157,18 +206,19 @@ async function save() { saving.value = true; error.value = ""; try { - await deploymentsApi.updateMetadata(props.deploymentName, { - healthcheck: { - type: checkType.value, - service: service.value, - port: containerPort, - path: checkType.value === "http" ? path.value : "", - interval: props.metadata?.healthcheck?.interval || "30s", - success_statuses: checkType.value === "http" ? acceptedStatuses || [] : [], - response_contains: checkType.value === "http" ? responseContains.value : "", - command: checkType.value === "exec" ? command.value.trim() : "", - }, - }); + const nextCheck: HealthCheck = { + type: checkType.value, + service: service.value, + port: containerPort, + path: checkType.value === "http" ? path.value : "", + interval: props.metadata?.healthcheck?.interval || "30s", + success_statuses: checkType.value === "http" ? acceptedStatuses || [] : [], + response_contains: checkType.value === "http" ? responseContains.value : "", + command: checkType.value === "exec" ? command.value.trim() : "", + }; + const healthchecks = configuredChecks.value.filter((check) => check.service !== service.value); + healthchecks.push(nextCheck); + await deploymentsApi.updateMetadata(props.deploymentName, { healthcheck: emptyHealthCheck(), healthchecks }); emit("saved"); } catch (cause: any) { error.value = cause.response?.data?.error || cause.message || "The health check could not be saved."; @@ -176,6 +226,26 @@ async function save() { saving.value = false; } } + +async function removeCheck(name: string) { + if (!name) return; + saving.value = true; + error.value = ""; + try { + const healthchecks = configuredChecks.value.filter((check) => check.service !== name); + await deploymentsApi.updateMetadata(props.deploymentName, { healthcheck: emptyHealthCheck(), healthchecks }); + configuredChecks.value = healthchecks; + loadCheck(healthchecks[0]?.service || props.services[0] || ""); + } catch (cause: any) { + error.value = cause.response?.data?.error || cause.message || "The health check could not be removed."; + } finally { + saving.value = false; + } +} + +function emptyHealthCheck(): HealthCheck { + return { path: "", interval: "" }; +} 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..1ecee73 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,25 @@ 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.defaults.baseURL}${ + deploymentName ? `/deployments/${deploymentName}/backups/${id}/download` : `/backups/${id}/download` + }`, getDeploymentBackups: (name: string, limit?: number) => apiClient.get<{ backups: Backup[] }>(`/deployments/${name}/backups`, { @@ -1770,7 +1809,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 +2090,7 @@ export type ClusterCapability = | "fleet.read" | "deployments.read" | "deployments.run" + | "deployments.manage" | "capacity.read" | "capacity.offer" | "events.publish" @@ -2151,13 +2194,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..." >