From 2cef9eb7439e559a7cbd5d75da18a0beee8708f8 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 15 Jul 2026 15:41:35 +0100 Subject: [PATCH 1/4] feat(deployments): Add a container files tab A deployment's own directory is browsable and editable, but whatever the running service keeps to itself is not, and that is usually where the configuration a user wants to read lives. Today the only way to it is a terminal. The new tab lists what a running service holds and can bring any path onto the host, after which the existing Files tab edits it and the container reads the same content. This is the opposite direction to mounting a host path into a container, which hides whatever was there. Browsing needs the service running and an image that ships a shell, since the engine cannot list a directory. That case is explained rather than left as a bare failure. --- src/components/ContainerFilesTab.test.ts | 112 +++++++ src/components/ContainerFilesTab.vue | 372 +++++++++++++++++++++++ src/services/api.ts | 26 ++ src/views/DeploymentDetailView.test.ts | 5 +- src/views/DeploymentDetailView.vue | 21 +- 5 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 src/components/ContainerFilesTab.test.ts create mode 100644 src/components/ContainerFilesTab.vue diff --git a/src/components/ContainerFilesTab.test.ts b/src/components/ContainerFilesTab.test.ts new file mode 100644 index 0000000..6b7521d --- /dev/null +++ b/src/components/ContainerFilesTab.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import ContainerFilesTab from "./ContainerFilesTab.vue"; +import { containerFilesApi } from "@/services/api"; + +vi.mock("@/services/api", () => ({ + containerFilesApi: { + list: vi.fn(), + materialize: vi.fn(), + }, +})); + +// Shaped like what the agent returns, which mirrors a real `ls -lA` line from +// nginx:alpine. +const listing = { + data: { + path: "/etc/nginx", + service: "app", + files: [ + { name: "conf.d", path: "/etc/nginx/conf.d", size: 4096, mode: "drwxr-xr-x", is_dir: true, is_symlink: false }, + { + name: "nginx.conf", + path: "/etc/nginx/nginx.conf", + size: 648, + mode: "-rw-r--r--", + is_dir: false, + is_symlink: false, + modified_raw: "Jun 17 15:58", + }, + ], + }, +}; + +const mountTab = () => + mount(ContainerFilesTab, { + props: { deploymentName: "test-app", serviceNames: ["app", "db"] }, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + stubs: { ConfirmModal: { template: "
", props: ["visible"] } }, + }, + }); + +describe("ContainerFilesTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(containerFilesApi.list).mockResolvedValue(listing as any); + }); + + it("lists what the running service holds", async () => { + const wrapper = mountTab(); + await flushPromises(); + + expect(containerFilesApi.list).toHaveBeenCalledWith("test-app", "app", "/"); + expect(wrapper.text()).toContain("conf.d"); + expect(wrapper.text()).toContain("nginx.conf"); + }); + + it("navigates into a directory", async () => { + const wrapper = mountTab(); + await flushPromises(); + + const confd = wrapper.findAll(".cf-name").find((b) => b.text().includes("conf.d")); + await confd!.trigger("click"); + await flushPromises(); + + expect(containerFilesApi.list).toHaveBeenLastCalledWith("test-app", "app", "/etc/nginx/conf.d"); + }); + + it("brings a path onto the host and reports where it landed", async () => { + vi.mocked(containerFilesApi.materialize).mockResolvedValue({ + data: { + deployment: "test-app", + service: "app", + container_path: "/etc/nginx/nginx.conf", + host_path: "./nginx.conf", + }, + } as any); + + const wrapper = mountTab(); + await flushPromises(); + + // The row action confirms first, so drive the confirmed path directly. + (wrapper.vm as any).pending = "/etc/nginx/nginx.conf"; + await (wrapper.vm as any).materialize(); + await flushPromises(); + + expect(containerFilesApi.materialize).toHaveBeenCalledWith("test-app", "app", { + container_path: "/etc/nginx/nginx.conf", + }); + expect(wrapper.emitted("materialized")?.[0]).toEqual([ + { hostPath: "./nginx.conf", containerPath: "/etc/nginx/nginx.conf" }, + ]); + }); + + it("explains why an image without a shell cannot be browsed", async () => { + vi.mocked(containerFilesApi.list).mockRejectedValue({ + response: { + data: { + error: "failed to list /: no shell", + hint: "the service must be running and its image must provide a shell to be browsed", + }, + }, + }); + + const wrapper = mountTab(); + await flushPromises(); + + expect(wrapper.text()).toContain("no shell"); + expect(wrapper.text()).toContain("must provide a shell"); + }); +}); diff --git a/src/components/ContainerFilesTab.vue b/src/components/ContainerFilesTab.vue new file mode 100644 index 0000000..caf4912 --- /dev/null +++ b/src/components/ContainerFilesTab.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index eb59b1d..5728f6f 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -643,6 +643,32 @@ export interface FilesInfo { file_count: number; } +export interface ContainerFile { + name: string; + path: string; + size: number; + mode: string; + is_dir: boolean; + is_symlink: boolean; + link_target?: string; + modified_raw?: string; +} + +export const containerFilesApi = { + list: (deploymentName: string, service: string, path: string = "/") => + apiClient.get<{ path: string; service: string; files: ContainerFile[] }>( + `/deployments/${deploymentName}/container-files/${service}`, + { params: { path } }, + ), + // Copies a container path onto the host and mounts it back, so it becomes an + // ordinary file the deployment's own file browser can edit. + materialize: (deploymentName: string, service: string, data: { container_path: string; host_path?: string }) => + apiClient.post<{ deployment: string; service: string; container_path: string; host_path: string }>( + `/deployments/${deploymentName}/container-files/${service}/materialize`, + data, + ), +}; + export const filesApi = { list: (deploymentName: string, path: string = "/") => apiClient.get<{ files: FileInfo[] }>(`/deployments/${deploymentName}/files`, { diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts index 5ab4634..5c06290 100644 --- a/src/views/DeploymentDetailView.test.ts +++ b/src/views/DeploymentDetailView.test.ts @@ -159,11 +159,11 @@ describe("DeploymentDetailView", () => { }); describe("Tab navigation", () => { - it("displays all nine tabs", async () => { + it("displays all ten tabs", async () => { const wrapper = mountView(); await flushPromises(); const tabs = wrapper.findAll(".tab-btn"); - expect(tabs.length).toBe(9); + expect(tabs.length).toBe(10); }); it("has Overview tab", async () => { @@ -252,6 +252,7 @@ describe("DeploymentDetailView", () => { expect((wrapper.vm as any).tabs).toEqual([ { id: "overview", label: "Overview", icon: "pi pi-info-circle" }, { id: "files", label: "Files", icon: "pi pi-folder" }, + { id: "container-files", label: "Container Files", icon: "lucide:box" }, { id: "logs", label: "Logs", icon: "pi pi-file-edit" }, { id: "terminal", label: "Terminal", icon: "pi pi-desktop" }, { id: "environment", label: "Environment", icon: "pi pi-list" }, diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 99f4555..0a6c5e2 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -86,7 +86,8 @@ :class="{ active: activeTab === tab.id }" @click="activeTab = tab.id" > - + + {{ tab.label }}
+
+ +
+
{ } }; +// A path brought out of the container is an ordinary file on the host now, so +// send the user to the browser that edits it, and refresh the compose view that +// just gained the mount. +const handleMaterialized = async () => { + await fetchDeployment(); + activeTab.value = "files"; +}; + const handleComposeMount = async (mount: { sourcePath: string; targetPath: string; From 3591dce3bce76fa74d1bc80ac84b6651b7a83d39 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 15 Jul 2026 15:58:38 +0100 Subject: [PATCH 2/4] fix(deployments): Move container files under the Files tab Container files were their own tab, which split browsing across two places when the two are the same task: find a path, then read or edit it. Files now switches between the host and a running service, and bringing a path out of the container drops straight back to the host view where it can be edited. The panel had also styled itself, so it sat flush against the tab's edges and drew colours the theme does not set, which left it looking unlike the rest of the app in either theme. It uses the design tokens now, and scrolls a long directory inside the pane rather than stretching the tab. --- ...ab.test.ts => ContainerFilesPanel.test.ts} | 6 +- ...erFilesTab.vue => ContainerFilesPanel.vue} | 219 ++++++++++-------- src/views/DeploymentDetailView.test.ts | 5 +- src/views/DeploymentDetailView.vue | 82 ++++++- 4 files changed, 201 insertions(+), 111 deletions(-) rename src/components/{ContainerFilesTab.test.ts => ContainerFilesPanel.test.ts} (96%) rename src/components/{ContainerFilesTab.vue => ContainerFilesPanel.vue} (62%) diff --git a/src/components/ContainerFilesTab.test.ts b/src/components/ContainerFilesPanel.test.ts similarity index 96% rename from src/components/ContainerFilesTab.test.ts rename to src/components/ContainerFilesPanel.test.ts index 6b7521d..c5e0dbb 100644 --- a/src/components/ContainerFilesTab.test.ts +++ b/src/components/ContainerFilesPanel.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; import { createTestingPinia } from "@pinia/testing"; -import ContainerFilesTab from "./ContainerFilesTab.vue"; +import ContainerFilesPanel from "./ContainerFilesPanel.vue"; import { containerFilesApi } from "@/services/api"; vi.mock("@/services/api", () => ({ @@ -33,7 +33,7 @@ const listing = { }; const mountTab = () => - mount(ContainerFilesTab, { + mount(ContainerFilesPanel, { props: { deploymentName: "test-app", serviceNames: ["app", "db"] }, global: { plugins: [createTestingPinia({ createSpy: vi.fn })], @@ -41,7 +41,7 @@ const mountTab = () => }, }); -describe("ContainerFilesTab", () => { +describe("ContainerFilesPanel", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(containerFilesApi.list).mockResolvedValue(listing as any); diff --git a/src/components/ContainerFilesTab.vue b/src/components/ContainerFilesPanel.vue similarity index 62% rename from src/components/ContainerFilesTab.vue rename to src/components/ContainerFilesPanel.vue index caf4912..a69d902 100644 --- a/src/components/ContainerFilesTab.vue +++ b/src/components/ContainerFilesPanel.vue @@ -1,5 +1,5 @@