diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 9359e5a57..c574de7e5 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -229,6 +229,11 @@ class TerminalServerConnection(BaseModel): config: Optional[dict] = None + # Orchestrator policy fields + server_type: Optional[str] = None # "orchestrator", "terminal" + policy_id: Optional[str] = None + policy: Optional[dict] = None # cached policy data + model_config = ConfigDict(extra="allow") diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index fe0381686..6448784cd 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -75,6 +75,12 @@ async def proxy_terminal( ) target_url = f"{base_url}/{path}" + + # Route through orchestrator policy endpoint if policy_id is set + policy_id = connection.get("policy_id") + if policy_id: + target_url = f"{base_url}/p/{policy_id}/{path}" + if request.query_params: target_url += f"?{request.query_params}" @@ -236,14 +242,18 @@ async def ws_terminal( # Build upstream WebSocket URL (no token in URL) ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://") - auth_type = connection.get("auth_type", "bearer") + # Route through orchestrator policy endpoint if policy_id is set + policy_id = connection.get("policy_id") upstream_params = {} # For orchestrator-backed servers, pass user_id upstream_params["user_id"] = user.id import urllib.parse - upstream_url = f"{ws_base}/api/terminals/{session_id}" + if policy_id: + upstream_url = f"{ws_base}/p/{policy_id}/api/terminals/{session_id}" + else: + upstream_url = f"{ws_base}/api/terminals/{session_id}" if upstream_params: upstream_url += f"?{urllib.parse.urlencode(upstream_params)}" diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e60544261..17fc2cca5 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -914,9 +914,17 @@ async def set_terminal_servers(request: Request): enabled = connection.get("enabled", True) + base_url = connection.get("url", "").rstrip("/") + policy_id = connection.get("policy_id", "") + + # Orchestrator connections route through /p/{policy_id}/ — the + # OpenAPI spec lives on the proxied terminal, not the orchestrator. + if connection.get("server_type") == "orchestrator" and policy_id: + base_url = f"{base_url}/p/{policy_id}" + server_configs.append( { - "url": connection.get("url", ""), + "url": base_url, "key": connection.get("key", ""), "auth_type": connection.get("auth_type", "bearer"), "path": connection.get("path", "/openapi.json"), diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index c94cffcb6..51236ff52 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -229,6 +229,85 @@ export const setTerminalServerConnections = async (token: string, connections: o return res; }; +/** + * Detect whether a terminal server URL points to an Orchestrator or a direct + * Open Terminal instance. + * + * - GET {url}/api/v1/policies → 200 → "orchestrator" + * - GET {url}/api/config → 200 → "terminal" + * - Neither → null + */ +export const detectTerminalServerType = async ( + url: string, + key: string +): Promise<'orchestrator' | 'terminal' | null> => { + const baseUrl = url.replace(/\/$/, ''); + const headers: Record = {}; + if (key) { + headers['Authorization'] = `Bearer ${key}`; + } + + // Orchestrators expose a policies API; plain terminals don't. + try { + const res = await fetch(`${baseUrl}/api/v1/policies`, { headers }); + if (res.ok) return 'orchestrator'; + } catch { + // ignore + } + + // Fall back to open-terminal config endpoint. + try { + const res = await fetch(`${baseUrl}/api/config`, { headers }); + if (res.ok) return 'terminal'; + } catch { + // ignore + } + + return null; +}; + +/** + * Create or update a policy on the orchestrator. + * PUT {url}/api/v1/policies/{policyId} + */ +export const putOrchestratorPolicy = async ( + url: string, + key: string, + policyId: string, + policyData: object +): Promise => { + let error = null; + + const baseUrl = url.replace(/\/$/, ''); + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (key) { + headers['Authorization'] = `Bearer ${key}`; + } + + const res = await fetch(`${baseUrl}/api/v1/policies/${encodeURIComponent(policyId)}`, { + method: 'PUT', + headers, + body: JSON.stringify(policyData) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const verifyToolServerConnection = async (token: string, connection: object) => { let error = null; diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte index 6a29e1fba..cad02d190 100644 --- a/src/lib/components/AddTerminalServerModal.svelte +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -11,6 +11,7 @@ import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import { detectTerminalServerType, putOrchestratorPolicy } from '$lib/apis/configs'; import { getTerminalConfig } from '$lib/apis/terminal'; export let show = false; @@ -32,6 +33,18 @@ let showAccessControlModal = false; let accessGrants: any[] = []; + // Policy / auto-detect state + let serverType: 'orchestrator' | 'terminal' | null = null; + let verifying = false; + let policyId = ''; + let policyImage = ''; + let policyEnvPairs: { key: string; value: string }[] = []; + let policyCpu = '1'; + let policyMemory = '1Gi'; + let policyStorage = 'ephemeral'; + let policyStorageSize = '5Gi'; + let policyIdleTimeout = 30; + const init = () => { if (connection) { id = connection?.id ?? ''; @@ -42,6 +55,24 @@ path = connection?.path ?? '/openapi.json'; enabled = connection?.enabled ?? true; accessGrants = connection?.config?.access_grants ?? []; + + // Restore policy state + serverType = connection?.server_type ?? null; + policyId = connection?.policy_id ?? ''; + + const p = connection?.policy ?? {}; + policyImage = p.image ?? ''; + policyIdleTimeout = p.idle_timeout_minutes ?? 30; + policyStorage = p.storage ? 'persistent' : 'ephemeral'; + policyStorageSize = p.storage ?? '5Gi'; + + // Restore env pairs + const env = p.env ?? {}; + policyEnvPairs = Object.entries(env).map(([k, v]) => ({ key: k, value: v as string })); + + // Restore resources + policyCpu = p.cpu_limit ?? '1'; + policyMemory = p.memory_limit ?? '1Gi'; } else { id = ''; url = ''; @@ -51,6 +82,16 @@ path = '/openapi.json'; enabled = false; accessGrants = []; + + serverType = null; + policyId = ''; + policyImage = ''; + policyEnvPairs = []; + policyCpu = '1'; + policyMemory = '1Gi'; + policyStorage = 'ephemeral'; + policyStorageSize = '5Gi'; + policyIdleTimeout = 30; } }; @@ -65,16 +106,81 @@ return; } - const res = await getTerminalConfig(_url, key); + verifying = true; + try { + if (admin) { + // Admin: detect orchestrator vs terminal + const type = await detectTerminalServerType(_url, key); - if (res) { - toast.success($i18n.t('Server connection verified')); - } else { + if (type) { + serverType = type; + toast.success( + $i18n.t('Connected ({{type}})', { + type: type === 'orchestrator' ? 'Orchestrator' : 'Terminal' + }) + ); + // Default policy_id to connection id when orchestrator detected + if (type === 'orchestrator' && !policyId) { + policyId = + id || + name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || + 'default'; + } + } else { + serverType = null; + toast.error($i18n.t('Server connection failed')); + } + } else { + // Non-admin: simple terminal verification + const res = await getTerminalConfig(_url, key); + if (res) { + toast.success($i18n.t('Server connection verified')); + } else { + toast.error($i18n.t('Server connection failed')); + } + } + } catch { + serverType = null; toast.error($i18n.t('Server connection failed')); + } finally { + verifying = false; } }; - const submitHandler = () => { + const buildPolicyData = (): object => { + const data: Record = {}; + + if (policyImage) data.image = policyImage; + if (policyCpu) data.cpu_limit = policyCpu; + if (policyMemory) data.memory_limit = policyMemory; + + if (policyStorage === 'persistent') { + data.storage = policyStorageSize; + } + + if (policyIdleTimeout > 0) { + data.idle_timeout_minutes = policyIdleTimeout; + } + + // Env vars + const env: Record = {}; + for (const pair of policyEnvPairs) { + if (pair.key.trim()) { + env[pair.key.trim()] = pair.value; + } + } + if (Object.keys(env).length > 0) { + data.env = env; + } + + return data; + }; + + const submitHandler = async () => { if (url === '') { toast.error($i18n.t('Please enter a valid URL')); return; @@ -83,6 +189,16 @@ // Remove trailing slash url = url.replace(/\/$/, ''); + // Save policy to orchestrator if applicable + if (serverType === 'orchestrator' && admin && policyId) { + try { + await putOrchestratorPolicy(url, key, policyId, buildPolicyData()); + } catch (err) { + toast.error($i18n.t('Failed to save policy: {{error}}', { error: err })); + return; + } + } + const result = { ...(admin && id.trim() ? { id: id.trim() } : {}), url, @@ -93,7 +209,11 @@ enabled: enabled, config: { ...(admin ? { access_grants: accessGrants } : {}) - } + }, + // Policy fields + ...(serverType ? { server_type: serverType } : {}), + ...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {}), + ...(serverType === 'orchestrator' ? { policy: buildPolicyData() } : {}) }; onSubmit(result); @@ -202,25 +322,241 @@ verifyHandler(); }} type="button" + disabled={verifying} aria-label={$i18n.t('Verify Connection')} > - + {#if verifying} + + + + + {:else} + + {/if} + + {#if serverType === 'orchestrator' && admin} +
+
+
+
+ {$i18n.t('Policy ID')} +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('Image')} + ({$i18n.t('optional')}) +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('CPU')} +
+
+
+ +
+
+
+
+
+ {$i18n.t('Memory')} +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('Storage')} +
+
+
+
+ +
+ {#if policyStorage === 'persistent'} +
+ +
+ {/if} +
+
+ +
+
+
+ {$i18n.t('Idle Timeout')} + ({$i18n.t('min')}) +
+
+
+ +
+
+
+ + +
+
+
+
+ {$i18n.t('Environment Variables')} +
+ +
+ {#each policyEnvPairs as pair, idx} +
+ + + +
+ {/each} +
+
+ {/if} +