This commit is contained in:
Timothy Jaeryang Baek
2026-03-12 20:37:41 -05:00
parent 05f314bae4
commit d85b52bfc2
5 changed files with 460 additions and 22 deletions
+5
View File
@@ -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")
+12 -2
View File
@@ -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)}"
+9 -1
View File
@@ -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"),
+79
View File
@@ -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<string, string> = {};
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<object | null> => {
let error = null;
const baseUrl = url.replace(/\/$/, '');
const headers: Record<string, string> = {
'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;
+355 -19
View File
@@ -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<string, any> = {};
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<string, string> = {};
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')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
{#if verifying}
<svg
class="w-4 h-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>
<!-- Policy section (orchestrator only, admin only) -->
{#if serverType === 'orchestrator' && admin}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Policy ID')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-id"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={policyId}
placeholder="python-ds"
autocomplete="off"
disabled={edit && !!connection?.policy_id}
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Image')}
<span class="opacity-50">({$i18n.t('optional')})</span>
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-image"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={policyImage}
placeholder="ghcr.io/open-webui/open-terminal:latest"
autocomplete="off"
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('CPU')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-cpu"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={policyCpu}
placeholder="1"
autocomplete="off"
/>
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Memory')}
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="policy-memory"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={policyMemory}
placeholder="1Gi"
autocomplete="off"
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Storage')}
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={policyStorage}
>
<option value="ephemeral">{$i18n.t('Ephemeral')}</option>
<option value="persistent">{$i18n.t('Persistent')}</option>
</select>
</div>
{#if policyStorage === 'persistent'}
<div class="flex flex-1 items-center">
<input
id="policy-storage-size"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={policyStorageSize}
placeholder="5Gi"
autocomplete="off"
/>
</div>
{/if}
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Idle Timeout')}
<span class="opacity-50">({$i18n.t('min')})</span>
</div>
</div>
<div class="flex flex-1 items-center">
<input
id="idle-timeout"
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="number"
min="0"
bind:value={policyIdleTimeout}
placeholder="30"
autocomplete="off"
/>
</div>
</div>
</div>
<!-- Env Vars -->
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center mb-0.5">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Environment Variables')}
</div>
<button
type="button"
class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
on:click={() =>
(policyEnvPairs = [...policyEnvPairs, { key: '', value: '' }])}
>
+ {$i18n.t('Add')}
</button>
</div>
{#each policyEnvPairs as pair, idx}
<div class="flex gap-1.5 mb-1">
<input
class={`flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={pair.key}
placeholder="KEY"
/>
<input
class={`flex-[2] text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={pair.value}
placeholder="value"
/>
<button
type="button"
class="text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition px-1"
on:click={() =>
(policyEnvPairs = policyEnvPairs.filter((_, i) => i !== idx))}
>
<XMark className={'size-3'} />
</button>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<button
type="button"