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
184 changes: 180 additions & 4 deletions src/components/FileBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@
<button v-if="!file.is_dir" class="action-btn" title="Download" @click="downloadFile(file)">
<i class="pi pi-download" />
</button>
<button
class="action-btn"
:class="{ 'is-mounted': fileMounts(file).length > 0 }"
:title="mountTooltip(file)"
@click="openMountModal(file)"
>
<i class="pi pi-link" />
</button>
<button class="action-btn delete" title="Delete" @click="confirmDelete(file)">
<i class="pi pi-trash" />
</button>
Expand Down Expand Up @@ -170,6 +178,14 @@
<button v-if="!file.is_dir" class="action-btn" title="Download" @click="downloadFile(file)">
<i class="pi pi-download" />
</button>
<button
class="action-btn"
:class="{ 'is-mounted': fileMounts(file).length > 0 }"
:title="mountTooltip(file)"
@click="openMountModal(file)"
>
<i class="pi pi-link" />
</button>
<button class="action-btn delete" title="Delete" @click="confirmDelete(file)">
<i class="pi pi-trash" />
</button>
Expand Down Expand Up @@ -240,6 +256,52 @@
</div>
</div>

<div v-if="showMountModal" class="modal-overlay" @click.self="showMountModal = false">
<div class="modal-container small">
<div class="modal-header">
<h3>
<i class="pi pi-link" />
Add Compose Mount
</h3>
</div>
<div class="modal-body">
<div class="mount-source">
<span>Host path</span>
<code>{{ mountSourcePath }}</code>
</div>
<div class="form-group">
<label>Service</label>
<select v-model="mountServiceName" class="form-input">
<option v-for="service in mountServiceOptions" :key="service" :value="service">
{{ service }}
</option>
</select>
</div>
<div class="form-group">
<label>Container path</label>
<input
v-model="mountTargetPath"
type="text"
class="form-input"
placeholder="/app/storage"
@keyup.enter="confirmMount"
/>
</div>
<label class="checkbox-label">
<input v-model="mountReadOnly" type="checkbox" />
<span>Read only</span>
</label>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showMountModal = false">Cancel</button>
<button class="btn btn-primary" :disabled="!mountTargetPath.trim()" @click="confirmMount">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'Add Mount' button lacks a visual loading state. If the API call takes time, the user might click multiple times. While the modal closes immediately in confirmMount, it is better practice to disable the button or show a spinner if the operation is asynchronous.

Suggested change
<button class="btn btn-primary" :disabled="!mountTargetPath.trim()" @click="confirmMount">
<button class="btn btn-primary" :disabled="!mountTargetPath.trim() || isSubmitting" @click="confirmMount">
<i class="pi" :class="isSubmitting ? 'pi-spin pi-spinner' : 'pi-check'" />
Add Mount
</button>

<i class="pi pi-check" />
Add Mount
</button>
</div>
</div>
</div>

<div v-if="showEditorModal" class="modal-overlay">
<div class="modal-container editor-modal">
<div class="modal-header">
Expand Down Expand Up @@ -298,9 +360,25 @@ import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import { filesApi, type FileInfo } from "@/services/api";
import { useNotificationsStore } from "@/stores/notifications";
import { toComposeRelativePath, type ComposeMount } from "@/utils/compose";

const props = defineProps<{
deploymentName: string;
serviceNames?: string[];
mounts?: ComposeMount[];
}>();

const emit = defineEmits<{
mountCompose: [
mount: {
sourcePath: string;
targetPath: string;
serviceName: string;
readOnly: boolean;
isDirectory: boolean;
name: string;
},
];
}>();

const notifications = useNotificationsStore();
Expand All @@ -324,6 +402,12 @@ const showDeleteModal = ref(false);
const fileToDelete = ref<FileInfo | null>(null);
const deleting = ref(false);

const showMountModal = ref(false);
const fileToMount = ref<FileInfo | null>(null);
const mountServiceName = ref("");
const mountTargetPath = ref("");
const mountReadOnly = ref(false);

const showHiddenFiles = ref(true);
const viewMode = ref<"list" | "grid">("list");

Expand All @@ -339,6 +423,37 @@ const editorExtensions = [yaml(), oneDark];

const fileModified = computed(() => fileContent.value !== originalContent.value);

const mountServiceOptions = computed(() => {
const names = props.serviceNames?.filter(Boolean) || [];
return names.length > 0 ? names : [props.deploymentName];
});

const mountSourcePath = computed(() => {
if (!fileToMount.value) return "";
return toComposeRelativePath(fileToMount.value.path);
});

const mountsBySource = computed(() => {
const grouped = new Map<string, ComposeMount[]>();
for (const mount of props.mounts || []) {
const list = grouped.get(mount.source);
if (list) list.push(mount);
else grouped.set(mount.source, [mount]);
}
return grouped;
});

const fileMounts = (file: FileInfo): ComposeMount[] => {
return mountsBySource.value.get(toComposeRelativePath(file.path)) || [];
};

const mountTooltip = (file: FileInfo): string => {
const matches = fileMounts(file);
if (matches.length === 0) return "Add compose mount";
const services = Array.from(new Set(matches.map((m) => m.service))).join(", ");
return `Mounted in ${services}`;
};

const pathParts = computed(() => {
return currentPath.value.split("/").filter((p) => p.length > 0);
});
Expand Down Expand Up @@ -475,6 +590,29 @@ const confirmDelete = (file: FileInfo) => {
showDeleteModal.value = true;
};

const openMountModal = (file: FileInfo) => {
fileToMount.value = file;
mountServiceName.value = mountServiceOptions.value[0] || props.deploymentName;
mountTargetPath.value = file.path;
mountReadOnly.value = !file.is_dir;
showMountModal.value = true;
};

const confirmMount = () => {
if (!fileToMount.value || !mountTargetPath.value.trim()) return;

emit("mountCompose", {
sourcePath: mountSourcePath.value,
targetPath: mountTargetPath.value.trim(),
serviceName: mountServiceName.value,
readOnly: mountReadOnly.value,
isDirectory: fileToMount.value.is_dir,
name: fileToMount.value.name,
});
showMountModal.value = false;
fileToMount.value = null;
};

const deleteFile = async () => {
if (!fileToDelete.value) return;

Expand Down Expand Up @@ -831,7 +969,7 @@ onMounted(() => {

.file-list-header {
display: grid;
grid-template-columns: 1fr 100px 180px 100px;
grid-template-columns: 1fr 100px 180px 116px;
padding: var(--space-2) var(--space-4);
background: var(--color-gray-50);
border-bottom: 1px solid var(--color-gray-200);
Expand All @@ -842,7 +980,7 @@ onMounted(() => {

.file-item {
display: grid;
grid-template-columns: 1fr 100px 180px 100px;
grid-template-columns: 1fr 100px 180px 116px;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-100);
cursor: pointer;
Expand Down Expand Up @@ -904,8 +1042,8 @@ onMounted(() => {
}

.action-btn {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
Expand All @@ -922,6 +1060,15 @@ onMounted(() => {
color: var(--color-gray-900);
}

.action-btn.is-mounted {
background: var(--color-success-50);
color: var(--color-success-700);
}

.action-btn.is-mounted:hover {
background: var(--color-success-100);
}

.action-btn.delete {
background: var(--color-danger-50);
color: var(--color-danger-600);
Expand Down Expand Up @@ -994,6 +1141,35 @@ onMounted(() => {
font-size: var(--text-sm);
}

.mount-source {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-3);
}

.mount-source span {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-gray-700);
}

.mount-source code {
padding: var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-gray-100);
color: var(--color-gray-700);
word-break: break-all;
}

.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-gray-700);
cursor: pointer;
}

.modal-footer {
padding: var(--space-4);
border-top: 1px solid var(--color-gray-200);
Expand Down
19 changes: 19 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ export const deploymentsApi = {
apiClient.post<{ message: string; action_id: string; output: string }>(`/deployments/${name}/actions/${actionId}`),
logs: (name: string) => apiClient.get(`/deployments/${name}/logs`),
getComposeFile: (name: string) => apiClient.get(`/deployments/${name}/compose`),
addComposeMount: (
name: string,
mount: {
source_path: string;
target_path: string;
service_name: string;
read_only: boolean;
selinux?: "" | "z" | "Z";
},
) =>
apiClient.post<{
message: string;
name: string;
filename: string;
content: string;
service_name: string;
mount: string;
added: boolean;
}>(`/deployments/${name}/compose/mount`, mount),
getStats: (name: string) =>
apiClient.get<{
deployment: string;
Expand Down
94 changes: 94 additions & 0 deletions src/utils/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { extractComposeMounts, extractComposeServiceNames, toComposeRelativePath } from "./compose";

describe("toComposeRelativePath", () => {
it("converts deployment-root file paths into relative compose paths", () => {
expect(toComposeRelativePath("/storage")).toBe("./storage");
expect(toComposeRelativePath("config/app.php")).toBe("config/app.php");
});
it("keeps root as compose project root", () => {
expect(toComposeRelativePath("/")).toBe(".");
});
});

describe("extractComposeServiceNames", () => {
it("extracts top-level compose service names", () => {
const compose = `services:
web:
image: nginx
app:
image: node
networks:
proxy:
external: true
`;

expect(extractComposeServiceNames(compose)).toEqual(["web", "app"]);
});

it("ignores nested keys inside service blocks", () => {
const compose = `services:
web:
image: nginx
environment:
APP_ENV: production
`;

expect(extractComposeServiceNames(compose)).toEqual(["web"]);
});
});

describe("extractComposeMounts", () => {
it("returns short-syntax bind mounts per service", () => {
const compose = `services:
app:
image: nginx
volumes:
- ./storage:/var/www/storage
- ./config:/etc/nginx:ro
`;

expect(extractComposeMounts(compose)).toEqual([
{ service: "app", source: "./storage", target: "/var/www/storage", readOnly: false },
{ service: "app", source: "./config", target: "/etc/nginx", readOnly: true },
]);
});

it("parses SELinux relabel options", () => {
const compose = `services:
app:
volumes:
- ./data:/data:Z
- ./shared:/shared:z
- ./readonly:/ro:ro,Z
`;

expect(extractComposeMounts(compose)).toEqual([
{ service: "app", source: "./data", target: "/data", readOnly: false, selinux: "Z" },
{ service: "app", source: "./shared", target: "/shared", readOnly: false, selinux: "z" },
{ service: "app", source: "./readonly", target: "/ro", readOnly: true, selinux: "Z" },
]);
});

it("collects mounts across multiple services", () => {
const compose = `services:
web:
volumes:
- ./a:/a
worker:
image: x
volumes:
- ./b:/b
`;

expect(extractComposeMounts(compose)).toEqual([
{ service: "web", source: "./a", target: "/a", readOnly: false },
{ service: "worker", source: "./b", target: "/b", readOnly: false },
]);
});

it("returns empty for missing compose", () => {
expect(extractComposeMounts("")).toEqual([]);
expect(extractComposeMounts("not yaml")).toEqual([]);
});
});
Loading
Loading