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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 9 additions & 4 deletions src/components/ContainerTerminal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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}`;

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 URL host and path concatenation should ensure that the path starts with a single slash to avoid malformed URLs if path or url.host formatting varies.

Suggested change
return `${protocol}//${url.host}${path}`;
return `${protocol}//${url.host}${path.startsWith('/') ? '' : '/'}${path}`;

}

return `${protocol}//${window.location.host}/api/containers/${props.containerId}/exec`;
return `${protocol}//${window.location.host}${path}`;
};

let authenticated = false;
Expand Down Expand Up @@ -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");
};
Expand Down
19 changes: 16 additions & 3 deletions src/components/FileBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 () => {

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 default value of showHiddenFiles is set to true (line 579) but then potentially overwritten by an async call. If the API call is slow, the UI might flicker from showing to hiding hidden files. Consider a loading state or a more robust sync mechanism if this is critical.

Suggested change
const loadShowHiddenSetting = async () => {
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 (err) {
console.debug("Could not load hidden files setting, using default", err);
}
};

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");

Expand Down Expand Up @@ -1078,7 +1090,8 @@ watch(showHiddenFiles, () => {
fetchFiles();
});

onMounted(() => {
onMounted(async () => {
await loadShowHiddenSetting();
refreshFiles();
document.addEventListener("click", onDocumentClick);
});
Expand Down
109 changes: 100 additions & 9 deletions src/components/NewDeploymentModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down Expand Up @@ -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" />
Expand Down Expand Up @@ -1044,7 +1081,6 @@
<input
type="checkbox"
:checked="getMountSelection(mount.id)?.enabled"

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 :disabled="mount.required" attribute was removed. While the PR description mentions that required mounts stay editable, ensure the backend/agent can handle a user deselecting a mount that the template identifies as 'required'.

Suggested change
:checked="getMountSelection(mount.id)?.enabled"
:checked="getMountSelection(mount.id)?.enabled"
@change="toggleMount(mount.id)"

:disabled="mount.required"
@change="toggleMount(mount.id)"
/>
<span class="mount-name">{{ mount.name }}</span>
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
if (!app) {
if (!app) {
form.mounts = [];
form.networking.ports = [{ containerPort: 80, hostPort: "", expose: true }];
return;
}

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);

Expand Down Expand Up @@ -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 || [];
});

Expand Down Expand Up @@ -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;
}
};
Expand Down Expand Up @@ -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: "" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 app.container_port as the fallback if available.

Suggested change
const firstPort = form.networking.ports[0] || { containerPort: 80, hostPort: "" };
const fallbackPort = templatePresetApp.value?.container_port || 80;
const firstPort = form.networking.ports[0] || { containerPort: fallbackPort, host_port: "" };

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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -2292,6 +2381,7 @@ watch(
errors.composeContent = "";
selectedQuickApp.value = "";
selectedTemplateContent.value = "";
templatePreset.value = "";
deploymentMode.value = "";
currentStep.value = 0;
generatedSubdomain.value = "";
Expand All @@ -2310,6 +2400,7 @@ watch(deploymentMode, (newMode, oldMode) => {
if (oldMode && newMode !== oldMode) {
selectedQuickApp.value = "";
selectedTemplateContent.value = "";
templatePreset.value = "";
form.name = "";
form.image = "";
form.composeContent = "";
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ export const aiApi = {
auto_run: boolean;
message: string;
context?: string;
seed?: boolean;
}) => apiClient.post<AISession>("/ai/sessions", body),
sessionMessage: (id: string, message: string, context?: string) =>
apiClient.post<AISession>(`/ai/sessions/${id}/messages`, { message, context }),
Expand Down Expand Up @@ -408,6 +409,7 @@ export interface MountSelection {

export interface ComposeGenerateOptions {
name: string;
image?: string;
container_port?: number;
map_ports?: boolean;
host_port?: string;
Expand Down
3 changes: 3 additions & 0 deletions src/stores/assist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
9 changes: 5 additions & 4 deletions src/stores/assist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {};
Expand All @@ -83,6 +83,7 @@ export const useAssistStore = defineStore("assist", () => {
auto_run: autoRun.value,
message,
context,
seed,
});
session.value = response.data;
} catch (err: any) {
Expand Down
Loading
Loading