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
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+ Whether dotfiles are visible when the file manager opens.
+
+
+
+
-
-
-
-
-
{{ error || (connecting ? "Connecting..." : "Click Connect to open system terminal") }}
-
-
+
@@ -35,130 +34,11 @@