From 731ea83560593398a24e9b06a45ff828d1d67795 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:35:14 +0100 Subject: [PATCH 01/11] fix(fleet): Open peer deployment details Peer deployment cards now open a compact read-only overview. Failed peer requests show the response returned by the server. --- src/components/RemoteDeploymentOverview.vue | 228 ++++++++++++++++++++ src/services/api.ts | 7 + src/views/DeploymentDetailView.test.ts | 32 ++- src/views/DeploymentDetailView.vue | 24 ++- src/views/DeploymentsView.test.ts | 3 + src/views/DeploymentsView.vue | 20 +- 6 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 src/components/RemoteDeploymentOverview.vue diff --git a/src/components/RemoteDeploymentOverview.vue b/src/components/RemoteDeploymentOverview.vue new file mode 100644 index 0000000..72c0346 --- /dev/null +++ b/src/components/RemoteDeploymentOverview.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index c1b2cc4..2b3fd34 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -2144,6 +2144,13 @@ 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/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts index 69a6a9a..ca9a833 100644 --- a/src/views/DeploymentDetailView.test.ts +++ b/src/views/DeploymentDetailView.test.ts @@ -6,7 +6,7 @@ import { useAuthStore } from "@/stores/auth"; const mockRoute = { params: { name: "test-app" }, - query: {}, + query: {} as Record, }; vi.mock("vue-router", () => ({ @@ -18,6 +18,21 @@ vi.mock("vue-router", () => ({ })); vi.mock("@/services/api", () => ({ + clusterApi: { + getDeployment: vi.fn().mockResolvedValue({ + data: { + deployment: { + name: "test-app", + status: "running", + path: "/deployments/test-app", + services: [{ name: "web", status: "running", container_id: "abc123", image: "nginx:1.27" }], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + proxy_status: { exposed: true, domain: "test-app.example.com", ssl_enabled: true }, + }, + }), + }, deploymentsApi: { get: vi.fn().mockResolvedValue({ data: { @@ -135,6 +150,7 @@ describe("DeploymentDetailView", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + mockRoute.query = {}; }); afterEach(() => { @@ -178,6 +194,20 @@ describe("DeploymentDetailView", () => { expect(wrapper.find(".detail-header").exists()).toBe(true); }); + it("loads peer deployment details and shows the peer context", async () => { + const { clusterApi, deploymentsApi } = await import("@/services/api"); + mockRoute.query = { server: "prod-2" }; + const wrapper = mountView(); + await flushPromises(); + + expect(clusterApi.getDeployment).toHaveBeenCalledWith("prod-2", "test-app"); + expect(deploymentsApi.get).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain("Managed on"); + expect(wrapper.text()).toContain("prod-2"); + expect(wrapper.text()).toContain("nginx:1.27"); + expect(wrapper.find(".detail-tabs").exists()).toBe(false); + }); + it("contains detail tabs section", async () => { const wrapper = mountView(); await flushPromises(); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index eaabb88..7d639a3 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -13,7 +13,7 @@ -
+
Diagnose Try Again
+ + @@ -327,7 +322,7 @@ import { RotateCw, FileText, Inbox, - Settings, + Eye, ExternalLink, Globe, Database, @@ -465,7 +460,10 @@ const onDeploymentCreated = () => { }; const goToDeployment = (deployment: ManagedDeployment) => { - if (deployment.local) router.push(`/deployments/${deployment.name}`); + router.push({ + path: `/deployments/${encodeURIComponent(deployment.name)}`, + query: deployment.local ? {} : { server: deployment.server }, + }); }; const getServiceClass = (service: Service) => { From 7c310aa3e0bc510eeae1a4ee62e58e47ce14c016 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 23:58:38 +0100 Subject: [PATCH 02/11] fix(auth): Preserve scoped user access Users can load assigned deployments without Fleet access. Service pages and controls now follow their independent read, write, and delete permissions. --- src/components/NotificationsSettings.test.ts | 15 ++++++++- src/components/NotificationsSettings.vue | 3 +- src/components/PermissionPicker.test.ts | 6 +++- src/components/PermissionPicker.vue | 34 ++++++++++++++++++++ src/components/StorageBackupsSettings.vue | 4 +-- src/layouts/DashboardLayout.vue | 8 +++-- src/router/index.ts | 8 ++--- src/services/api.ts | 1 + src/types/index.ts | 5 +++ src/views/DeploymentsView.test.ts | 33 +++++++++++++++++++ src/views/DeploymentsView.vue | 8 +++++ src/views/ObjectBucketView.vue | 2 +- src/views/ObjectStoreDetailView.vue | 10 +++--- src/views/ObjectStoresView.vue | 6 ++-- 14 files changed, 122 insertions(+), 21 deletions(-) diff --git a/src/components/NotificationsSettings.test.ts b/src/components/NotificationsSettings.test.ts index e357aab..cf02804 100644 --- a/src/components/NotificationsSettings.test.ts +++ b/src/components/NotificationsSettings.test.ts @@ -2,8 +2,9 @@ import { flushPromises, mount } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import NotificationsSettings from "./NotificationsSettings.vue"; +const hasPermission = vi.fn((_permission: string) => true); vi.mock("@/stores/auth", () => ({ - useAuthStore: () => ({ hasPermission: () => true }), + useAuthStore: () => ({ hasPermission }), })); vi.mock("@/stores/notifications", () => ({ @@ -25,6 +26,7 @@ describe("NotificationsSettings", () => { beforeEach(async () => { window.history.replaceState({}, "", "/"); vi.clearAllMocks(); + hasPermission.mockReturnValue(true); const { notificationsApi } = await import("@/services/api"); vi.mocked(notificationsApi.getTargets).mockResolvedValue({ data: { targets: [{ id: "ops", name: "Operations", url: "********", kind: "email", enabled: true }] }, @@ -157,4 +159,15 @@ describe("NotificationsSettings", () => { expect(wrapper.text()).toContain("The node stopped responding."); expect(wrapper.text()).toContain("inc-42"); }); + + it("keeps notification readers out of mutation controls", async () => { + hasPermission.mockImplementation((permission: string) => permission === "notifications:read"); + const wrapper = mountSettings(); + await flushPromises(); + + await wrapper.findAll(".section-tabs button")[2].trigger("click"); + expect(wrapper.text()).not.toContain("Add target"); + expect(wrapper.text()).not.toContain("Test"); + expect(wrapper.text()).toContain("View"); + }); }); diff --git a/src/components/NotificationsSettings.vue b/src/components/NotificationsSettings.vue index d604791..7f2e242 100644 --- a/src/components/NotificationsSettings.vue +++ b/src/components/NotificationsSettings.vue @@ -154,6 +154,7 @@ >{{ kindOf(target) }}
("incidents"); const targets = ref([]); const rules = ref([]); diff --git a/src/components/PermissionPicker.test.ts b/src/components/PermissionPicker.test.ts index 3b56086..307de76 100644 --- a/src/components/PermissionPicker.test.ts +++ b/src/components/PermissionPicker.test.ts @@ -11,7 +11,7 @@ describe("PermissionPicker", () => { it("renders all permission groups", () => { const wrapper = mountPicker({ modelValue: [] }); const groups = wrapper.findAll(".permission-group"); - expect(groups.length).toBe(20); + expect(groups.length).toBe(24); }); it("displays group labels", () => { @@ -21,6 +21,10 @@ describe("PermissionPicker", () => { expect(text).toContain("Containers"); expect(text).toContain("Databases"); expect(text).toContain("Infrastructure"); + expect(text).toContain("Object Storage"); + expect(text).toContain("Notifications"); + expect(text).toContain("Fleet"); + expect(text).toContain("Configuration"); expect(text).toContain("DNS"); expect(text).toContain("Audit"); }); diff --git a/src/components/PermissionPicker.vue b/src/components/PermissionPicker.vue index b210319..35cf35b 100644 --- a/src/components/PermissionPicker.vue +++ b/src/components/PermissionPicker.vue @@ -130,6 +130,7 @@ const permissionGroups: PermissionGroup[] = [ permissions: [ { value: "system:read", label: "Read", level: "read" }, { value: "system:write", label: "Write", level: "write" }, + { value: "system:files", label: "Files", level: "write" }, ], }, { @@ -200,6 +201,31 @@ const permissionGroups: PermissionGroup[] = [ { value: "backups:delete", label: "Delete", level: "delete" }, ], }, + { + key: "storage", + label: "Object Storage", + permissions: [ + { value: "storage:read", label: "Read", level: "read" }, + { value: "storage:write", label: "Write", level: "write" }, + { value: "storage:delete", label: "Delete", level: "delete" }, + ], + }, + { + key: "notifications", + label: "Notifications", + permissions: [ + { value: "notifications:read", label: "Read", level: "read" }, + { value: "notifications:write", label: "Write", level: "write" }, + ], + }, + { + key: "cluster", + label: "Fleet", + permissions: [ + { value: "cluster:read", label: "Read", level: "read" }, + { value: "cluster:write", label: "Write", level: "write" }, + ], + }, { key: "users", label: "Users", @@ -226,6 +252,14 @@ const permissionGroups: PermissionGroup[] = [ { value: "settings:write", label: "Write", level: "write" }, ], }, + { + key: "config", + label: "Configuration", + permissions: [ + { value: "config:read", label: "Read", level: "read" }, + { value: "config:write", label: "Write", level: "write" }, + ], + }, { key: "audit", label: "Audit", diff --git a/src/components/StorageBackupsSettings.vue b/src/components/StorageBackupsSettings.vue index 8751598..74c8e19 100644 --- a/src/components/StorageBackupsSettings.vue +++ b/src/components/StorageBackupsSettings.vue @@ -177,8 +177,8 @@ import { useNotificationsStore } from "@/stores/notifications"; const auth = useAuthStore(); const notifications = useNotificationsStore(); -const canWriteCreds = auth.hasPermission("backups:write"); -const canDeleteCreds = auth.hasPermission("backups:delete"); +const canWriteCreds = auth.hasPermission("storage:write"); +const canDeleteCreds = auth.hasPermission("storage:delete"); const canWriteDests = auth.hasPermission("config:write"); const creds = ref([]); diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 54311e5..0f0e576 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -205,7 +205,7 @@