diff --git a/package-lock.json b/package-lock.json index 0c22ba0..084af34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@flatrun/ui", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@flatrun/ui", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@codemirror/lang-sql": "^6.10.0", diff --git a/package.json b/package.json index 1e880f4..1d9d310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flatrun/ui", - "version": "0.2.0", + "version": "0.3.0", "description": "Web interface for FlatRun container orchestration", "author": "FlatRun", "license": "MIT", diff --git a/src/components/ContainerTerminal.vue b/src/components/ContainerTerminal.vue index 8c59595..1249f23 100644 --- a/src/components/ContainerTerminal.vue +++ b/src/components/ContainerTerminal.vue @@ -27,7 +27,11 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; const props = defineProps<{ - containerId: string; + containerId?: string; + // wsPath overrides the container exec endpoint, so any PTY stream + // speaking the same protocol (e.g. the system terminal) can reuse + // this component. + wsPath?: string; }>(); const emit = defineEmits<{ @@ -52,13 +56,14 @@ let explicitCloseMessage = ""; const getWebSocketUrl = () => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const apiUrl = import.meta.env.VITE_API_URL || ""; + const path = props.wsPath || `/api/containers/${props.containerId}/exec`; if (apiUrl.startsWith("http")) { const url = new URL(apiUrl); - return `${protocol}//${url.host}/api/containers/${props.containerId}/exec`; + return `${protocol}//${url.host}${path}`; } - return `${protocol}//${window.location.host}/api/containers/${props.containerId}/exec`; + return `${protocol}//${window.location.host}${path}`; }; let authenticated = false; @@ -166,7 +171,7 @@ const connect = () => { socket.onerror = () => { connectionStatus.value = "error"; - statusMessage.value = "Connection failed. Check if container is running."; + statusMessage.value = props.wsPath ? "Connection failed." : "Connection failed. Check if container is running."; authenticated = false; emit("error", "WebSocket connection failed"); }; diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue index ff99a2d..73cd5b6 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -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 () => { + 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); }); diff --git a/src/components/NewDeploymentModal.vue b/src/components/NewDeploymentModal.vue index aefed94..cfafe76 100644 --- a/src/components/NewDeploymentModal.vue +++ b/src/components/NewDeploymentModal.vue @@ -277,6 +277,25 @@ +
+ + + + Your compose file stays as-is; the app's directories, default environment files and permissions + are prepared on deploy + +
+
@@ -343,6 +362,24 @@ Docker Hub, GHCR, or any registry
+
+ + + + Prepares the app's directories and environment file; bind mounts can be enabled in the next step + +
+
+
+ App Template + {{ templatePresetApp.name }} +
Domain @@ -1448,7 +1488,33 @@ const existingDeployments = ref([]); 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) { + 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([]); 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: "" }; 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 = { 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", diff --git a/src/services/api.ts b/src/services/api.ts index 3b5eeaf..5a16923 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -369,6 +369,7 @@ export const aiApi = { auto_run: boolean; message: string; context?: string; + seed?: boolean; }) => apiClient.post("/ai/sessions", body), sessionMessage: (id: string, message: string, context?: string) => apiClient.post(`/ai/sessions/${id}/messages`, { message, context }), @@ -408,6 +409,7 @@ export interface MountSelection { export interface ComposeGenerateOptions { name: string; + image?: string; container_port?: number; map_ports?: boolean; host_port?: string; diff --git a/src/stores/assist.test.ts b/src/stores/assist.test.ts index 030c742..d580978 100644 --- a/src/stores/assist.test.ts +++ b/src/stores/assist.test.ts @@ -63,12 +63,15 @@ describe("assist store", () => { const store = useAssistStore(); await store.open({ scope: "deployment", deployment: "myapp", subject: "myapp", seedMessage: "diagnose" }); + // Seeded prompts are flagged so the agent keeps them out of the + // visible transcript; the model still receives them. expect(aiApi.createSession).toHaveBeenCalledWith({ scope: "deployment", deployment: "myapp", auto_run: true, message: "diagnose", context: undefined, + seed: true, }); expect(store.session?.id).toBe("ais_1"); }); diff --git a/src/stores/assist.ts b/src/stores/assist.ts index dbd0dd1..3d6b206 100644 --- a/src/stores/assist.ts +++ b/src/stores/assist.ts @@ -16,8 +16,8 @@ export interface AssistContext { scope: "system" | "deployment"; deployment?: string; subject: string; - // seedMessage is the short prompt shown in the transcript; seedContext - // is bulky material (logs, output) sent to the model but not shown. + // seedMessage and seedContext are sent to the model but kept out of + // the visible transcript; only messages the user types are shown. seedMessage?: string; seedContext?: string; autoRun?: boolean; @@ -65,11 +65,11 @@ export const useAssistStore = defineStore("assist", () => { autoRun.value = ctx.autoRun ?? true; if (!(await ensureEnabled())) return; if (ctx.seedMessage) { - await send(ctx.seedMessage, ctx.seedContext); + await send(ctx.seedMessage, ctx.seedContext, true); } } - async function send(message: string, context?: string) { + async function send(message: string, context?: string, seed = false) { if (loading.value) return; error.value = ""; suggestionOutputs.value = {}; @@ -83,6 +83,7 @@ export const useAssistStore = defineStore("assist", () => { auto_run: autoRun.value, message, context, + seed, }); session.value = response.data; } catch (err: any) { diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index b7c12c2..32a48fa 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -79,6 +79,27 @@
+
+
+ +

File Manager

+
+
+
+ + Whether dotfiles are visible when the file manager opens. +
+
+
+
@@ -972,7 +993,7 @@ diff --git a/src/views/SystemTerminalView.vue b/src/views/SystemTerminalView.vue index 773bcee..45d0f68 100644 --- a/src/views/SystemTerminalView.vue +++ b/src/views/SystemTerminalView.vue @@ -6,11 +6,11 @@

Host shell governed by global terminal protection settings

- - @@ -18,13 +18,12 @@
-
-
-
- -

{{ error || (connecting ? "Connecting..." : "Click Connect to open system terminal") }}

-
-
+
@@ -35,130 +34,11 @@