-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Deploy template selection, PTY terminal and assistant transcript fixes #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6d5cd52
541cac0
963077e
fd5593f
d0c4724
b826deb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -475,7 +475,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from "vue" | |||||||||||||||||||||||||
| import { Codemirror } from "vue-codemirror"; | ||||||||||||||||||||||||||
| import { yaml } from "@codemirror/lang-yaml"; | ||||||||||||||||||||||||||
| import { oneDark } from "@codemirror/theme-one-dark"; | ||||||||||||||||||||||||||
| import { createDeploymentFileApi, type FileBrowserApi, type FileInfo } from "@/services/api"; | ||||||||||||||||||||||||||
| import { configApi, createDeploymentFileApi, type FileBrowserApi, type FileInfo } from "@/services/api"; | ||||||||||||||||||||||||||
| import { useNotificationsStore } from "@/stores/notifications"; | ||||||||||||||||||||||||||
| import { toComposeRelativePath, type ComposeMount } from "@/utils/compose"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -576,9 +576,21 @@ const permissionsModeOctal = computed(() => { | |||||||||||||||||||||||||
| return "0" + ((n >> 6) & 7) + ((n >> 3) & 7) + (n & 7); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const showHiddenFiles = ref(false); | ||||||||||||||||||||||||||
| const showHiddenFiles = ref(true); | ||||||||||||||||||||||||||
| const hideSystemFolders = ref(true); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const loadShowHiddenSetting = async () => { | ||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default value of
Suggested change
|
||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| const response = await configApi.get("files.show_hidden"); | ||||||||||||||||||||||||||
| const value = response.data.entry?.value; | ||||||||||||||||||||||||||
| if (typeof value === "boolean") { | ||||||||||||||||||||||||||
| showHiddenFiles.value = value; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||
| // Keep the default when the setting cannot be loaded | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const SYSTEM_FOLDER_NAMES = new Set(["proc", "sys", "dev", "boot", "run", "lost+found", "var", "tmp", "snap"]); | ||||||||||||||||||||||||||
| const viewMode = ref<"list" | "grid">("list"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -1078,7 +1090,8 @@ watch(showHiddenFiles, () => { | |||||||||||||||||||||||||
| fetchFiles(); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| onMounted(() => { | ||||||||||||||||||||||||||
| onMounted(async () => { | ||||||||||||||||||||||||||
| await loadShowHiddenSetting(); | ||||||||||||||||||||||||||
| refreshFiles(); | ||||||||||||||||||||||||||
| document.addEventListener("click", onDocumentClick); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -277,6 +277,25 @@ | |||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div class="form-field"> | ||||||||||||||
| <label for="composeTemplatePreset">App Template (optional)</label> | ||||||||||||||
| <select | ||||||||||||||
| id="composeTemplatePreset" | ||||||||||||||
| v-model="templatePreset" | ||||||||||||||
| class="form-select" | ||||||||||||||
| @change="onTemplatePresetChange" | ||||||||||||||
| > | ||||||||||||||
| <option value="">None</option> | ||||||||||||||
| <option v-for="app in displayedQuickApps" :key="app.id" :value="app.id"> | ||||||||||||||
| {{ app.name }} | ||||||||||||||
| </option> | ||||||||||||||
| </select> | ||||||||||||||
| <span class="field-hint"> | ||||||||||||||
| Your compose file stays as-is; the app's directories, default environment files and permissions | ||||||||||||||
| are prepared on deploy | ||||||||||||||
| </span> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div class="advanced-options-section"> | ||||||||||||||
| <div class="advanced-options-header"> | ||||||||||||||
| <i class="pi pi-sliders-h" /> | ||||||||||||||
|
|
@@ -343,6 +362,24 @@ | |||||||||||||
| <span v-else class="field-hint">Docker Hub, GHCR, or any registry</span> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div class="form-field"> | ||||||||||||||
| <label for="imageTemplatePreset">App Template (optional)</label> | ||||||||||||||
| <select | ||||||||||||||
| id="imageTemplatePreset" | ||||||||||||||
| v-model="templatePreset" | ||||||||||||||
| class="form-select" | ||||||||||||||
| @change="onTemplatePresetChange" | ||||||||||||||
| > | ||||||||||||||
| <option value="">None</option> | ||||||||||||||
| <option v-for="app in displayedQuickApps" :key="app.id" :value="app.id"> | ||||||||||||||
| {{ app.name }} | ||||||||||||||
| </option> | ||||||||||||||
| </select> | ||||||||||||||
| <span class="field-hint"> | ||||||||||||||
| Prepares the app's directories and environment file; bind mounts can be enabled in the next step | ||||||||||||||
| </span> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div class="private-registry-toggle"> | ||||||||||||||
| <label class="toggle-option"> | ||||||||||||||
| <input v-model="form.registry.isPrivate" type="checkbox" /> | ||||||||||||||
|
|
@@ -1044,7 +1081,6 @@ | |||||||||||||
| <input | ||||||||||||||
| type="checkbox" | ||||||||||||||
| :checked="getMountSelection(mount.id)?.enabled" | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||
| :disabled="mount.required" | ||||||||||||||
| @change="toggleMount(mount.id)" | ||||||||||||||
| /> | ||||||||||||||
| <span class="mount-name">{{ mount.name }}</span> | ||||||||||||||
|
|
@@ -1304,6 +1340,10 @@ | |||||||||||||
| <span class="review-label">Mode</span> | ||||||||||||||
| <span class="review-value">Custom Compose</span> | ||||||||||||||
| </div> | ||||||||||||||
| <div v-if="deploymentMode !== 'easy' && templatePresetApp" class="review-item"> | ||||||||||||||
| <span class="review-label">App Template</span> | ||||||||||||||
| <span class="review-value">{{ templatePresetApp.name }}</span> | ||||||||||||||
| </div> | ||||||||||||||
| <div v-if="effectiveDomain" class="review-item full-width"> | ||||||||||||||
| <span class="review-label">Domain</span> | ||||||||||||||
| <span class="review-value domain"> | ||||||||||||||
|
|
@@ -1448,7 +1488,33 @@ const existingDeployments = ref<string[]>([]); | |||||||||||||
| const extensions = shallowRef([yaml(), oneDark]); | ||||||||||||||
|
|
||||||||||||||
| const deploymentMode = ref<"" | "easy" | "compose" | "image">(""); | ||||||||||||||
| const templatePreset = ref(""); | ||||||||||||||
| const showRegistryPassword = ref(false); | ||||||||||||||
|
|
||||||||||||||
| const templatePresetApp = computed(() => quickApps.value.find((a) => a.id === templatePreset.value) || null); | ||||||||||||||
|
|
||||||||||||||
| const effectiveTemplateId = computed(() => { | ||||||||||||||
| if (deploymentMode.value === "easy") { | ||||||||||||||
| return selectedQuickApp.value !== "custom" ? selectedQuickApp.value : ""; | ||||||||||||||
| } | ||||||||||||||
| return templatePreset.value; | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| const onTemplatePresetChange = () => { | ||||||||||||||
| const app = templatePresetApp.value; | ||||||||||||||
| if (!app) { | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When switching the template to 'None' (app is null), existing networking ports previously set by a template are not cleared. This might lead to unexpected port configurations if the user switches templates multiple times.
Suggested change
|
||||||||||||||
| form.mounts = []; | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
| if (deploymentMode.value === "image" && app.container_port) { | ||||||||||||||
| form.networking.ports = [{ containerPort: app.container_port, hostPort: "", expose: true }]; | ||||||||||||||
| } | ||||||||||||||
| form.mounts = (app.mounts || []).map((m) => ({ | ||||||||||||||
| id: m.id, | ||||||||||||||
| enabled: m.required, | ||||||||||||||
| type: m.type, | ||||||||||||||
| })); | ||||||||||||||
| }; | ||||||||||||||
| const existingCredentials = ref<RegistryCredential[]>([]); | ||||||||||||||
| const loadingCredentials = ref(false); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -1674,8 +1740,10 @@ const selectedQuickAppName = computed(() => { | |||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| const selectedTemplateMounts = computed(() => { | ||||||||||||||
| if (selectedQuickApp.value === "custom" || !selectedQuickApp.value) return []; | ||||||||||||||
| const app = quickApps.value.find((a) => a.id === selectedQuickApp.value); | ||||||||||||||
| // Compose mode keeps the user's compose untouched, so mount toggles would have no effect. | ||||||||||||||
| if (deploymentMode.value === "compose") return []; | ||||||||||||||
| if (!effectiveTemplateId.value) return []; | ||||||||||||||
| const app = quickApps.value.find((a) => a.id === effectiveTemplateId.value); | ||||||||||||||
| return app?.mounts || []; | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -1861,8 +1929,6 @@ const selectQuickApp = async (app: QuickApp) => { | |||||||||||||
| const toggleMount = (mountId: string) => { | ||||||||||||||
| const mount = form.mounts.find((m) => m.id === mountId); | ||||||||||||||
| if (mount) { | ||||||||||||||
| const templateMount = selectedTemplateMounts.value.find((m) => m.id === mountId); | ||||||||||||||
| if (templateMount?.required) return; | ||||||||||||||
| mount.enabled = !mount.enabled; | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
@@ -2210,14 +2276,13 @@ const nextStep = async () => { | |||||||||||||
| ) { | ||||||||||||||
| generatingCompose.value = true; | ||||||||||||||
| try { | ||||||||||||||
| const enabledMounts = form.mounts.filter((m) => m.enabled); | ||||||||||||||
| const firstPort = form.networking.ports[0] || { containerPort: 80, hostPort: "" }; | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When generating compose from an image with a template, the container port is hardcoded to 80 if no port is defined. It would be more consistent to use
Suggested change
|
||||||||||||||
| const response = await templatesApi.generateCompose(selectedQuickApp.value, { | ||||||||||||||
| name: form.name, | ||||||||||||||
| container_port: firstPort.containerPort, | ||||||||||||||
| map_ports: !!firstPort.hostPort, | ||||||||||||||
| host_port: firstPort.hostPort || undefined, | ||||||||||||||
| mounts: enabledMounts.length > 0 ? enabledMounts : undefined, | ||||||||||||||
| mounts: form.mounts.length > 0 ? form.mounts : undefined, | ||||||||||||||
| }); | ||||||||||||||
| form.composeContent = response.data.content; | ||||||||||||||
| } catch (error: any) { | ||||||||||||||
|
|
@@ -2230,7 +2295,31 @@ const nextStep = async () => { | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (currentStep.value === 2 && deploymentMode.value === "image") { | ||||||||||||||
| form.composeContent = buildComposeFromImage(); | ||||||||||||||
| if (templatePreset.value) { | ||||||||||||||
| generatingCompose.value = true; | ||||||||||||||
| try { | ||||||||||||||
| const firstPort = form.networking.ports[0] || { containerPort: 80, hostPort: "" }; | ||||||||||||||
| const response = await templatesApi.generateCompose(templatePreset.value, { | ||||||||||||||
| name: form.name, | ||||||||||||||
| image: form.image, | ||||||||||||||
| container_port: firstPort.containerPort, | ||||||||||||||
| map_ports: !!firstPort.hostPort, | ||||||||||||||
| host_port: firstPort.hostPort || undefined, | ||||||||||||||
| // The full selection list, so deselected required mounts are not | ||||||||||||||
| // re-applied as defaults by the agent. | ||||||||||||||
| mounts: form.mounts.length > 0 ? form.mounts : undefined, | ||||||||||||||
| }); | ||||||||||||||
| form.composeContent = response.data.content; | ||||||||||||||
| } catch (error: any) { | ||||||||||||||
| const msg = error.response?.data?.error || error.message; | ||||||||||||||
| notifications.error("Failed to generate compose", msg); | ||||||||||||||
| generatingCompose.value = false; | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
| generatingCompose.value = false; | ||||||||||||||
| } else { | ||||||||||||||
| form.composeContent = buildComposeFromImage(); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (currentStep.value < steps.value.length) { | ||||||||||||||
|
|
@@ -2292,6 +2381,7 @@ watch( | |||||||||||||
| errors.composeContent = ""; | ||||||||||||||
| selectedQuickApp.value = ""; | ||||||||||||||
| selectedTemplateContent.value = ""; | ||||||||||||||
| templatePreset.value = ""; | ||||||||||||||
| deploymentMode.value = ""; | ||||||||||||||
| currentStep.value = 0; | ||||||||||||||
| generatedSubdomain.value = ""; | ||||||||||||||
|
|
@@ -2310,6 +2400,7 @@ watch(deploymentMode, (newMode, oldMode) => { | |||||||||||||
| if (oldMode && newMode !== oldMode) { | ||||||||||||||
| selectedQuickApp.value = ""; | ||||||||||||||
| selectedTemplateContent.value = ""; | ||||||||||||||
| templatePreset.value = ""; | ||||||||||||||
| form.name = ""; | ||||||||||||||
| form.image = ""; | ||||||||||||||
| form.composeContent = ""; | ||||||||||||||
|
|
@@ -2403,7 +2494,7 @@ const handleCreate = async () => { | |||||||||||||
| const payload: Record<string, any> = { | ||||||||||||||
| name: form.name, | ||||||||||||||
| compose_content: form.composeContent, | ||||||||||||||
| template_id: selectedQuickApp.value !== "custom" ? selectedQuickApp.value : undefined, | ||||||||||||||
| template_id: effectiveTemplateId.value || undefined, | ||||||||||||||
| env_vars: form.envVars.filter((e) => e.key), | ||||||||||||||
| auto_start: form.autoStart, | ||||||||||||||
| use_shared_database: form.database.useSharedDatabase && form.database.mode === "shared", | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL host and path concatenation should ensure that the path starts with a single slash to avoid malformed URLs if
pathorurl.hostformatting varies.