This commit is contained in:
Timothy Jaeryang Baek
2026-06-22 16:47:48 +02:00
parent 223f484ded
commit 7e8153e889
3 changed files with 313 additions and 7 deletions
+103
View File
@@ -358,6 +358,24 @@ class TerminalServerPolicyForm(BaseModel):
policy_data: dict
class TerminalServerLifecycleForm(BaseModel):
url: str
key: str | None = ''
auth_type: str | None = 'bearer'
policy_id: str
lifecycle_data: dict
class TerminalServerRefreshForm(BaseModel):
url: str
key: str | None = ''
auth_type: str | None = 'bearer'
user_id: str | None = None
policy_id: str | None = None
only_idle: bool = True
reset: bool = False
@router.post('/terminal_servers/policy')
async def put_terminal_server_policy(
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
@@ -393,6 +411,91 @@ async def put_terminal_server_policy(
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
@router.post('/terminal_servers/lifecycle')
async def put_terminal_server_lifecycle(
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
):
"""
Proxy a policy lifecycle PUT to an orchestrator terminal server.
"""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
headers = {'Content-Type': 'application/json'}
if form_data.auth_type == 'bearer' and form_data.key:
headers['Authorization'] = f'Bearer {form_data.key}'
try:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
async with session.put(
lifecycle_url,
headers=headers,
json=form_data.lifecycle_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
return await resp.json()
detail = await resp.text()
raise HTTPException(status_code=resp.status, detail=detail)
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to save lifecycle to terminal server: {e}')
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
@router.post('/terminal_servers/refresh')
async def refresh_terminal_server_terminals(
request: Request, form_data: TerminalServerRefreshForm, user=Depends(get_admin_user)
):
"""
Proxy a terminal refresh request to an orchestrator terminal server.
"""
base_url = (form_data.url or '').rstrip('/')
if not base_url:
raise HTTPException(status_code=400, detail='Terminal server URL is required')
headers = {'Content-Type': 'application/json'}
if form_data.auth_type == 'bearer' and form_data.key:
headers['Authorization'] = f'Bearer {form_data.key}'
body = {
'only_idle': form_data.only_idle,
'reset': form_data.reset,
}
if form_data.user_id:
body['user_id'] = form_data.user_id
if form_data.policy_id:
body['policy_id'] = form_data.policy_id
try:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
refresh_url = f'{base_url}/api/v1/terminals/refresh'
async with session.post(
refresh_url,
headers=headers,
json=body,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
return await resp.json()
detail = await resp.text()
raise HTTPException(status_code=resp.status, detail=detail)
except HTTPException:
raise
except Exception as e:
log.debug(f'Failed to refresh terminals: {e}')
raise HTTPException(status_code=400, detail='Failed to refresh terminals')
@router.post('/tool_servers/verify')
async def verify_tool_servers_config(request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)):
"""
+89 -2
View File
@@ -1,7 +1,7 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types';
export const importConfig = async (token: string, config) => {
export const importConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/import`, {
@@ -275,7 +275,8 @@ export const putOrchestratorPolicy = async (
url: string,
key: string,
policyId: string,
policyData: object
policyData: object,
authType: string = 'bearer'
): Promise<object | null> => {
let error = null;
@@ -288,6 +289,7 @@ export const putOrchestratorPolicy = async (
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
auth_type: authType,
policy_id: policyId,
policy_data: policyData
})
@@ -309,6 +311,91 @@ export const putOrchestratorPolicy = async (
return res;
};
export const putOrchestratorLifecycle = async (
token: string,
url: string,
key: string,
policyId: string,
lifecycleData: object,
authType: string = 'bearer'
): Promise<object | null> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/lifecycle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
auth_type: authType,
policy_id: policyId,
lifecycle_data: lifecycleData
})
})
.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 refreshOrchestratorTerminals = async (
token: string,
url: string,
key: string,
body: {
user_id?: string;
policy_id?: string;
only_idle?: boolean;
reset?: boolean;
},
authType: string = 'bearer'
): Promise<object | null> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
url: url.replace(/\/$/, ''),
key,
auth_type: authType,
...body
})
})
.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;
};
/**
* Verify a terminal server connection via the backend proxy.
* Used for system/admin connections to avoid CORS issues and API key exposure.
@@ -1,7 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
import { settings } from '$lib/stores';
@@ -15,14 +15,16 @@
import {
detectTerminalServerType,
verifyTerminalServerConnection,
putOrchestratorPolicy
putOrchestratorPolicy,
putOrchestratorLifecycle,
refreshOrchestratorTerminals
} from '$lib/apis/configs';
import { getTerminalConfig } from '$lib/apis/terminal';
export let show = false;
export let edit = false;
export let direct = false;
export let connection = null;
export let connection: any = null;
export let onSubmit: Function = () => {};
export let onDelete: () => void = () => {};
@@ -42,6 +44,7 @@
// Policy / auto-detect state
let serverType: 'orchestrator' | 'terminal' | null = null;
let verifying = false;
let refreshing = false;
let policyId = '';
let policyImage = '';
let policyEnvPairs: { key: string; value: string }[] = [];
@@ -50,6 +53,13 @@
let policyStorage = 'ephemeral';
let policyStorageSize = '5Gi';
let policyIdleTimeout = 30;
let lifecycleJson = '{}';
let refreshOnlyIdle = true;
let refreshReset = false;
const stringifyJson = (value: object | null | undefined) => {
return JSON.stringify(value && Object.keys(value).length ? value : {}, null, 2);
};
const init = () => {
if (connection) {
@@ -79,6 +89,9 @@
// Restore resources
policyCpu = p.cpu_limit ?? '1';
policyMemory = p.memory_limit ?? '1Gi';
lifecycleJson = stringifyJson(connection?.lifecycle);
refreshOnlyIdle = true;
refreshReset = false;
} else {
id = '';
url = '';
@@ -98,6 +111,9 @@
policyStorage = 'ephemeral';
policyStorageSize = '5Gi';
policyIdleTimeout = 30;
lifecycleJson = '{}';
refreshOnlyIdle = true;
refreshReset = false;
}
};
@@ -162,6 +178,20 @@
}
};
const parseJson = (label: string, value: string): object | null => {
try {
const parsed = JSON.parse(value || '{}');
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
toast.error($i18n.t('{{label}} must be a JSON object', { label }));
return null;
}
return parsed;
} catch {
toast.error($i18n.t('{{label}} contains invalid JSON', { label }));
return null;
}
};
const buildPolicyData = (): object => {
const data: Record<string, any> = {};
@@ -191,6 +221,37 @@
return data;
};
const refreshHandler = async () => {
if (!policyId) {
toast.error($i18n.t('Policy ID is required'));
return;
}
refreshing = true;
try {
const result = await refreshOrchestratorTerminals(
localStorage.token,
url,
key,
{
policy_id: policyId,
only_idle: refreshOnlyIdle,
reset: refreshReset
},
auth_type
);
toast.success(
$i18n.t('Refresh requested: {{count}} terminal(s)', {
count: (result as { refreshed?: number } | null)?.refreshed ?? 0
})
);
} catch (err) {
toast.error($i18n.t('Failed to refresh terminals: {{error}}', { error: err }));
} finally {
refreshing = false;
}
};
const submitHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
@@ -201,9 +262,24 @@
url = url.replace(/\/$/, '');
// Save policy to orchestrator if applicable
let policyData = {};
let lifecycleData = {};
if (serverType === 'orchestrator' && !direct && policyId) {
const parsedLifecycle = parseJson('Lifecycle JSON', lifecycleJson);
if (!parsedLifecycle) return;
policyData = buildPolicyData();
lifecycleData = parsedLifecycle;
try {
await putOrchestratorPolicy(localStorage.token, url, key, policyId, buildPolicyData());
await putOrchestratorPolicy(localStorage.token, url, key, policyId, policyData, auth_type);
await putOrchestratorLifecycle(
localStorage.token,
url,
key,
policyId,
lifecycleData,
auth_type
);
} catch (err) {
toast.error($i18n.t('Failed to save policy: {{error}}', { error: err }));
return;
@@ -224,7 +300,7 @@
// Policy fields
...(serverType ? { server_type: serverType } : {}),
...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {}),
...(serverType === 'orchestrator' ? { policy: buildPolicyData() } : {})
...(serverType === 'orchestrator' ? { policy: policyData, lifecycle: lifecycleData } : {})
};
onSubmit(result);
@@ -566,6 +642,46 @@
{/each}
</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('Lifecycle JSON')}
</div>
</div>
<textarea
id="lifecycle-json"
class={`w-full min-h-24 resize-y text-xs bg-transparent font-mono rounded-lg p-2 border border-gray-200 dark:border-gray-800 ${($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={lifecycleJson}
spellcheck="false"
placeholder={`{\n "reset": {\n "schedule": "@weekly",\n "timezone": "UTC"\n }\n}`}
></textarea>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mt-2">
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<label class="flex items-center gap-1.5">
<input type="checkbox" bind:checked={refreshOnlyIdle} />
<span>{$i18n.t('Idle only')}</span>
</label>
<label class="flex items-center gap-1.5">
<input type="checkbox" bind:checked={refreshReset} />
<span>{$i18n.t('Reset persisted files')}</span>
</label>
</div>
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition"
disabled={refreshing}
on:click={refreshHandler}
>
{refreshing ? $i18n.t('Refreshing...') : $i18n.t('Refresh Terminals')}
</button>
</div>
{/if}
<div class="flex items-center justify-between">