Add remote desktop (noVNC) viewer to terminal server feature

- Backend: WebSocket proxy for noVNC/websockify with JWT auth via query param
- Frontend: DesktopViewer component with start/stop/refresh/fullscreen controls
- API helpers for desktop status, start, stop
- Unit tests for backend router and frontend API helpers

Ref: https://github.com/open-webui/open-terminal/pull/100
This commit is contained in:
volker 2026-03-31 20:44:28 +02:00
parent b10c70cfcf
commit 9d86ac8186
6 changed files with 1253 additions and 12 deletions

View file

@ -332,3 +332,149 @@ async def ws_terminal(
await ws.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# WebSocket proxy for remote desktop (noVNC / websockify)
# ---------------------------------------------------------------------------
_DESKTOP_WS_TIMEOUT = 10.0
async def _resolve_desktop_connection(ws: WebSocket, server_id: str):
"""Authenticate a desktop WebSocket and resolve the terminal server.
noVNC opens a raw binary WebSocket (RFB protocol) so it cannot send
a JSON auth message first. Instead the JWT is passed as a ``token``
query parameter on the WebSocket URL.
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*.
"""
from open_webui.utils.auth import decode_token
token = ws.query_params.get('token', '')
data = decode_token(token)
if data is None or 'id' not in data:
await ws.close(code=4001, reason='Invalid or missing token')
return None
user = Users.get_user_by_id(data['id'])
if user is None:
await ws.close(code=4001, reason='User not found')
return None
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if connection is None:
await ws.close(code=4004, reason='Terminal server not found')
return None
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
if not has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason='Access denied')
return None
return user, connection
async def _fetch_novnc_port(base_url: str, api_key: str) -> int:
"""Query the terminal server's ``GET /desktop`` endpoint and return the
noVNC port. Falls back to 6080 if the request fails.
"""
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=5, connect=3),
trust_env=True,
) as session:
headers = {}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
async with session.get(
f'{base_url}/desktop',
headers=headers,
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('novnc_port', 6080)
except Exception:
log.warning('Could not fetch desktop status from %s, defaulting to port 6080', base_url)
return 6080
@router.websocket('/{server_id}/desktop/ws')
async def ws_desktop(ws: WebSocket, server_id: str):
"""Proxy a noVNC WebSocket session to the terminal server's websockify.
The JWT is passed as a ``token`` query parameter because noVNC uses raw
binary RFB protocol and cannot send a JSON auth message. After
authentication the proxy connects to the terminal server's websockify
port (queried from ``GET /desktop``) and performs a raw binary WebSocket
relay.
"""
await ws.accept()
result = await _resolve_desktop_connection(ws, server_id)
if result is None:
return
user, connection = result
base_url = (connection.get('url') or '').rstrip('/')
if not base_url:
await ws.close(code=4003, reason='Terminal server URL not configured')
return
api_key = connection.get('key', '')
novnc_port = await _fetch_novnc_port(base_url, api_key)
from urllib.parse import urlparse
parsed = urlparse(base_url)
host = parsed.hostname or 'localhost'
ws_scheme = 'wss' if parsed.scheme == 'https' else 'ws'
upstream_url = f'{ws_scheme}://{host}:{novnc_port}/websockify'
session = aiohttp.ClientSession()
try:
async with session.ws_connect(upstream_url) as upstream:
import asyncio
async def _client_to_upstream():
try:
while True:
msg = await ws.receive()
if msg['type'] == 'websocket.disconnect':
break
elif 'bytes' in msg and msg['bytes']:
await upstream.send_bytes(msg['bytes'])
elif 'text' in msg and msg['text']:
await upstream.send_str(msg['text'])
except Exception:
pass
async def _upstream_to_client():
try:
async for msg in upstream:
if msg.type == aiohttp.WSMsgType.BINARY:
await ws.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_text(msg.data)
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR,
):
break
except Exception:
pass
await asyncio.gather(
_client_to_upstream(),
_upstream_to_client(),
return_exceptions=True,
)
except Exception as e:
log.exception('Desktop WebSocket proxy error: %s', e)
finally:
await session.close()
try:
await ws.close()
except Exception:
pass

View file

@ -0,0 +1,433 @@
"""Unit tests for the terminals reverse-proxy router.
Covers:
- _sanitize_proxy_path (path traversal prevention)
- _fetch_novnc_port (noVNC port discovery with fallback)
- HTTP proxy endpoints (list, proxy, desktop status/start)
- WebSocket route registration
"""
import json
import sys
import types
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Pre-populate sys.modules with lightweight stubs for the open_webui
# dependency chain so that ``open_webui.routers.terminals`` can be
# imported without pulling in peewee/sqlalchemy/etc.
# ---------------------------------------------------------------------------
def _setup_module_mocks():
needed = [
'open_webui.utils',
'open_webui.utils.auth',
'open_webui.utils.access_control',
'open_webui.models',
'open_webui.models.groups',
'open_webui.models.users',
'open_webui.internal',
'open_webui.internal.db',
]
for mod_name in needed:
if mod_name not in sys.modules:
sys.modules[mod_name] = types.ModuleType(mod_name)
sys.modules['open_webui.utils.auth'].get_verified_user = lambda: None
sys.modules['open_webui.utils.access_control'].has_connection_access = lambda *a, **kw: True
sys.modules['open_webui.models.groups'].Groups = MagicMock()
sys.modules['open_webui.models.users'].Users = MagicMock()
_setup_module_mocks()
from open_webui.routers.terminals import ( # noqa: E402
router,
_sanitize_proxy_path,
_fetch_novnc_port,
)
# ---------------------------------------------------------------------------
# _sanitize_proxy_path
# ---------------------------------------------------------------------------
class TestSanitizeProxyPath:
def test_simple_path(self):
assert _sanitize_proxy_path('api/config') == 'api/config'
def test_trailing_slash_preserved(self):
assert _sanitize_proxy_path('api/config/') == 'api/config/'
def test_encoded_slashes(self):
assert _sanitize_proxy_path('files/read%3Ffoo') == 'files/read?foo'
def test_dotdot_rejected(self):
assert _sanitize_proxy_path('../../../etc/passwd') is None
def test_dot_only_rejected(self):
assert _sanitize_proxy_path('.') is None
def test_empty_rejected(self):
assert _sanitize_proxy_path('') is None
def test_double_slash_cleaned(self):
assert _sanitize_proxy_path('foo//bar') == 'foo/bar'
def test_leading_slash_stripped(self):
assert _sanitize_proxy_path('/api/config') == 'api/config'
# ---------------------------------------------------------------------------
# _fetch_novnc_port
# ---------------------------------------------------------------------------
class TestFetchNovncPort:
@pytest.mark.asyncio
async def test_returns_port_from_server(self):
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={'running': True, 'novnc_port': 6090})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = AsyncMock()
mock_session.get = MagicMock(return_value=mock_resp)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session):
port = await _fetch_novnc_port('http://terminal:8080', 'key123')
assert port == 6090
@pytest.mark.asyncio
async def test_fallback_on_failure(self):
mock_session = AsyncMock()
mock_session.get = MagicMock(side_effect=Exception('connection refused'))
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session):
port = await _fetch_novnc_port('http://terminal:8080', 'key123')
assert port == 6080
def _make_app():
app = FastAPI()
app.include_router(router, prefix='/api/v1/terminals')
return app
@pytest.fixture()
def app():
return _make_app()
@pytest.fixture()
def client(app):
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture()
def mock_user():
user = MagicMock()
user.id = 'test-user-id'
user.email = 'test@example.com'
user.role = 'user'
return user
@pytest.fixture()
def mock_connection():
return {
'id': 'srv1',
'name': 'Test Server',
'url': 'http://terminal-server:8080',
'key': 'secret-key',
'auth_type': 'bearer',
'enabled': True,
'policy_id': None,
}
@pytest.fixture()
def mock_config(mock_connection):
return MagicMock(TERMINAL_SERVER_CONNECTIONS=[mock_connection])
def _override_auth(app, user):
from open_webui.utils.auth import get_verified_user
app.dependency_overrides[get_verified_user] = lambda: user
def _clear_auth(app):
from open_webui.utils.auth import get_verified_user
app.dependency_overrides.pop(get_verified_user, None)
# ---------------------------------------------------------------------------
# list terminal servers
# ---------------------------------------------------------------------------
class TestListTerminals:
def test_returns_configured_servers(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/')
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]['id'] == 'srv1'
assert data[0]['name'] == 'Test Server'
_clear_auth(app)
def test_filters_by_access(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=False),
patch('open_webui.routers.terminals.Groups') as MockGroups,
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/')
assert resp.status_code == 200
assert resp.json() == []
_clear_auth(app)
def test_skips_disabled_servers(self, client, app, mock_user):
_override_auth(app, mock_user)
conn = {
'id': 'srv2',
'name': 'Disabled',
'url': 'http://ts:8080',
'key': 'k',
'enabled': False,
}
app.state.config = MagicMock(TERMINAL_SERVER_CONNECTIONS=[conn])
with patch('open_webui.routers.terminals.Groups') as MockGroups:
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/')
assert resp.status_code == 200
assert resp.json() == []
_clear_auth(app)
# ---------------------------------------------------------------------------
# HTTP proxy: proxy_terminal
# ---------------------------------------------------------------------------
class TestProxyTerminal:
def test_404_for_unknown_server(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
resp = client.get('/api/v1/terminals/nonexistent/api/config')
assert resp.status_code == 404
_clear_auth(app)
def test_403_for_no_access(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=False),
patch('open_webui.routers.terminals.Groups') as MockGroups,
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/srv1/api/config')
assert resp.status_code == 403
_clear_auth(app)
def test_400_for_invalid_path(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/srv1/..%2F..%2Fetc%2Fpasswd')
assert resp.status_code == 400
_clear_auth(app)
def test_proxies_json_response(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
upstream_body = {'features': {'terminal': True, 'desktop': True}}
mock_upstream = AsyncMock()
mock_upstream.status = 200
mock_upstream.headers = {'content-type': 'application/json'}
mock_upstream.read = AsyncMock(return_value=json.dumps(upstream_body).encode())
mock_upstream.release = AsyncMock()
mock_session_instance = AsyncMock()
mock_session_instance.request = AsyncMock(return_value=mock_upstream)
mock_session_instance.close = AsyncMock()
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance),
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/srv1/api/config')
assert resp.status_code == 200
assert resp.json() == upstream_body
call_kwargs = mock_session_instance.request.call_args
target_url = call_kwargs.kwargs.get('url', call_kwargs.args[1] if len(call_kwargs.args) > 1 else '')
assert target_url == 'http://terminal-server:8080/api/config'
_clear_auth(app)
def test_proxies_with_bearer_auth_header(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
mock_upstream = AsyncMock()
mock_upstream.status = 200
mock_upstream.headers = {'content-type': 'application/json'}
mock_upstream.read = AsyncMock(return_value=b'{}')
mock_upstream.release = AsyncMock()
mock_session_instance = AsyncMock()
mock_session_instance.request = AsyncMock(return_value=mock_upstream)
mock_session_instance.close = AsyncMock()
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance),
):
MockGroups.get_groups_by_member_id.return_value = []
client.get('/api/v1/terminals/srv1/api/config')
call_kwargs = mock_session_instance.request.call_args
headers = call_kwargs.kwargs.get('headers', {})
assert headers.get('Authorization') == 'Bearer secret-key'
_clear_auth(app)
def test_proxies_desktop_status_endpoint(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
desktop_status = {
'running': True,
'display': ':0',
'vnc_port': 5900,
'novnc_port': 6080,
'screen_width': 1280,
'screen_height': 720,
}
mock_upstream = AsyncMock()
mock_upstream.status = 200
mock_upstream.headers = {'content-type': 'application/json'}
mock_upstream.read = AsyncMock(return_value=json.dumps(desktop_status).encode())
mock_upstream.release = AsyncMock()
mock_session_instance = AsyncMock()
mock_session_instance.request = AsyncMock(return_value=mock_upstream)
mock_session_instance.close = AsyncMock()
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance),
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/srv1/desktop')
assert resp.status_code == 200
data = resp.json()
assert data['running'] is True
assert data['novnc_port'] == 6080
_clear_auth(app)
def test_proxies_desktop_start_endpoint(self, client, app, mock_user, mock_config):
_override_auth(app, mock_user)
app.state.config = mock_config
start_response = {'running': True, 'novnc_port': 6080}
mock_upstream = AsyncMock()
mock_upstream.status = 200
mock_upstream.headers = {'content-type': 'application/json'}
mock_upstream.read = AsyncMock(return_value=json.dumps(start_response).encode())
mock_upstream.release = AsyncMock()
mock_session_instance = AsyncMock()
mock_session_instance.request = AsyncMock(return_value=mock_upstream)
mock_session_instance.close = AsyncMock()
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance),
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.post('/api/v1/terminals/srv1/desktop/start')
assert resp.status_code == 200
assert resp.json()['running'] is True
call_kwargs = mock_session_instance.request.call_args
assert call_kwargs.kwargs.get('method') == 'POST'
_clear_auth(app)
def test_503_for_empty_url(self, client, app, mock_user):
_override_auth(app, mock_user)
conn = {'id': 'srv3', 'name': 'No URL', 'url': '', 'key': 'k', 'enabled': True}
app.state.config = MagicMock(TERMINAL_SERVER_CONNECTIONS=[conn])
with (
patch('open_webui.routers.terminals.has_connection_access', return_value=True),
patch('open_webui.routers.terminals.Groups') as MockGroups,
):
MockGroups.get_groups_by_member_id.return_value = []
resp = client.get('/api/v1/terminals/srv3/api/config')
assert resp.status_code == 503
_clear_auth(app)
# ---------------------------------------------------------------------------
# WebSocket route registration
# ---------------------------------------------------------------------------
class TestDesktopWsRoute:
def test_ws_desktop_endpoint_registered(self, app):
routes = [r.path for r in app.routes]
assert '/api/v1/terminals/{server_id}/desktop/ws' in routes
def test_ws_terminal_endpoint_registered(self, app):
routes = [r.path for r in app.routes]
assert '/api/v1/terminals/{server_id}/api/terminals/{session_id}' in routes

View file

@ -0,0 +1,135 @@
/**
* Unit tests for the desktop (noVNC) API helpers in src/lib/apis/terminal/index.ts
*
* We mock fetch globally so no network calls are made.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/constants', () => ({
WEBUI_API_BASE_URL: 'http://localhost:8080/api'
}));
import {
getDesktopStatus,
startDesktop,
stopDesktop,
getDesktopViewerUrl,
getPortProxyUrl
} from '$lib/apis/terminal';
const BASE = 'http://terminal:8080';
const API_KEY = 'test-key';
beforeEach(() => {
vi.restoreAllMocks();
});
describe('getDesktopStatus', () => {
it('returns parsed DesktopStatus on success', async () => {
const body = {
running: true,
display: ':0',
vnc_port: 5900,
novnc_port: 6080,
screen_width: 1280,
screen_height: 720
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(body)
});
const result = await getDesktopStatus(BASE, API_KEY);
expect(result).toEqual(body);
expect(fetch).toHaveBeenCalledWith(
'http://terminal:8080/desktop',
expect.objectContaining({
headers: { Authorization: 'Bearer test-key' }
})
);
});
it('returns null on non-ok response', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false });
const result = await getDesktopStatus(BASE, API_KEY);
expect(result).toBeNull();
});
it('returns null on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'));
const result = await getDesktopStatus(BASE, API_KEY);
expect(result).toBeNull();
});
});
describe('startDesktop', () => {
it('posts to desktop/start and returns status', async () => {
const body = { running: true, novnc_port: 6080 };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(body)
});
const result = await startDesktop(BASE, API_KEY);
expect(result).toEqual(body);
expect(fetch).toHaveBeenCalledWith(
'http://terminal:8080/desktop/start',
expect.objectContaining({ method: 'POST' })
);
});
it('returns null on failure', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ detail: 'error' })
});
const result = await startDesktop(BASE, API_KEY);
expect(result).toBeNull();
});
});
describe('stopDesktop', () => {
it('returns true on success', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
const result = await stopDesktop(BASE, API_KEY);
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith(
'http://terminal:8080/desktop/stop',
expect.objectContaining({ method: 'POST' })
);
});
it('returns false on failure', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
const result = await stopDesktop(BASE, API_KEY);
expect(result).toBe(false);
});
});
describe('getDesktopViewerUrl', () => {
it('builds viewer URL with default port', () => {
expect(getDesktopViewerUrl('http://host:8080')).toBe('http://host:8080/proxy/6080/vnc.html');
});
it('builds viewer URL with custom port', () => {
expect(getDesktopViewerUrl('http://host:8080', 6090)).toBe(
'http://host:8080/proxy/6090/vnc.html'
);
});
it('strips trailing slash', () => {
expect(getDesktopViewerUrl('http://host:8080/')).toBe('http://host:8080/proxy/6080/vnc.html');
});
});
describe('getPortProxyUrl', () => {
it('builds proxy URL with path', () => {
expect(getPortProxyUrl('http://host:8080', 3000, 'api/data')).toBe(
'http://host:8080/proxy/3000/api/data'
);
});
it('builds proxy URL without path', () => {
expect(getPortProxyUrl('http://host:8080', 3000)).toBe('http://host:8080/proxy/3000/');
});
});

View file

@ -13,6 +13,7 @@ export type ListeningPort = {
export type TerminalFeatures = {
terminal?: boolean;
desktop?: boolean;
};
import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -304,6 +305,68 @@ export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''
return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`;
};
// ---------------------------------------------------------------------------
// Remote desktop (noVNC)
// ---------------------------------------------------------------------------
export type DesktopStatus = {
running: boolean;
display?: string;
vnc_port?: number;
novnc_port?: number;
screen_width?: number;
screen_height?: number;
};
export const getDesktopStatus = async (
baseUrl: string,
apiKey: string
): Promise<DesktopStatus | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/desktop`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
if (!res || !res.ok) return null;
return res.json().catch(() => null);
};
export const startDesktop = async (
baseUrl: string,
apiKey: string
): Promise<DesktopStatus | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/desktop/start`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` }
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
})
.catch((err) => {
console.error('open-terminal startDesktop error:', err);
return null;
});
return res;
};
export const stopDesktop = async (baseUrl: string, apiKey: string): Promise<boolean> => {
const url = `${baseUrl.replace(/\/$/, '')}/desktop/stop`;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
return res?.ok ?? false;
};
export const getDesktopViewerUrl = (baseUrl: string, novncPort: number = 6080): string => {
const base = baseUrl.replace(/\/$/, '');
return `${base}/proxy/${novncPort}/vnc.html`;
};
// ---------------------------------------------------------------------------
// Notebook execution
// ---------------------------------------------------------------------------

View file

@ -44,6 +44,7 @@
import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte';
import XTerminal from './XTerminal.svelte';
import DesktopViewer from './FileNav/DesktopViewer.svelte';
const i18n = getContext('i18n');
@ -55,11 +56,16 @@
let terminalExpanded = false;
let terminalHeight = 200; // px, default when expanded
let isDraggingHandle = false;
let isDraggingDesktopHandle = false;
let containerEl: HTMLElement;
let terminalConnected = false;
let terminalConnecting = false;
let terminalEnabled = true;
let desktopEnabled = false;
let desktopExpanded = false;
let desktopHeight = 300;
const toggleTerminal = () => {
terminalExpanded = !terminalExpanded;
};
@ -86,6 +92,28 @@
window.addEventListener('mouseup', onMouseUp);
};
const onDesktopHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDraggingDesktopHandle = true;
const startY = e.clientY;
const startHeight = desktopHeight;
const onMouseMove = (ev: MouseEvent) => {
const delta = startY - ev.clientY;
const maxH = containerEl ? containerEl.clientHeight - 100 : 500;
desktopHeight = Math.max(80, Math.min(maxH, startHeight + delta));
};
const onMouseUp = () => {
isDraggingDesktopHandle = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
// ── Directory state ──────────────────────────────────────────────────
let currentPath = savedPath;
let entries: FileEntry[] = [];
@ -249,6 +277,7 @@
if (terminalChanged) {
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
desktopEnabled = config?.features?.desktop === true;
}
const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
@ -365,22 +394,52 @@
clearFilePreview();
if (isImage(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
@ -493,7 +552,12 @@
const terminal = selectedTerminal;
if (!terminal) return;
const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`, chatId ?? undefined);
const result = await createDirectory(
terminal.url,
terminal.key,
`${currentPath}${name}`,
chatId ?? undefined
);
toast[result ? 'success' : 'error'](
$i18n.t(result ? 'Folder created' : 'Failed to create folder')
);
@ -554,7 +618,13 @@
const sourceDir = source.endsWith('/') ? source : source + '/';
if (destFolder.startsWith(sourceDir)) return;
const result = await moveEntry(terminal.url, terminal.key, source, destination, chatId ?? undefined);
const result = await moveEntry(
terminal.url,
terminal.key,
source,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
@ -573,7 +643,13 @@
if (oldPath === destination) return;
const result = await moveEntry(terminal.url, terminal.key, oldPath, destination, chatId ?? undefined);
const result = await moveEntry(
terminal.url,
terminal.key,
oldPath,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
@ -1316,6 +1392,63 @@
{/if}
</div>
<!-- Desktop bottom panel -->
{#if desktopEnabled}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
{#if desktopExpanded}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="relative cursor-row-resize group" on:mousedown={onDesktopHandleMouseDown}>
<div
class="h-px bg-transparent group-hover:bg-black/10 dark:group-hover:bg-white/10 transition"
></div>
<div class="absolute inset-x-0 -top-1.5 -bottom-1.5"></div>
</div>
{/if}
<button
class="w-full flex items-center gap-2 px-3 py-1 mb-0.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition"
on:click={() => (desktopExpanded = !desktopExpanded)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M1 4.75C1 3.784 1.784 3 2.75 3h14.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 17.25 14H2.75A1.75 1.75 0 0 1 1 12.25v-7.5Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h14.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75ZM4 15h12a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2Z"
clip-rule="evenodd"
/>
</svg>
<span class="font-medium">{$i18n.t('Desktop')}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 ml-auto transition-transform {desktopExpanded ? 'rotate-180' : ''}"
>
<path
fill-rule="evenodd"
d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z"
clip-rule="evenodd"
/>
</svg>
</button>
{#if desktopExpanded}
<div style="height: {desktopHeight}px" class="min-h-0">
<DesktopViewer
baseUrl={selectedTerminal?.url ?? ''}
apiKey={selectedTerminal?.key ?? ''}
overlay={overlay || isDraggingDesktopHandle}
/>
</div>
{/if}
</div>
{/if}
<!-- Port detection -->
{#if selectedTerminal && !selectedFile && previewPort === null}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
@ -1340,8 +1473,8 @@
<div class="relative cursor-row-resize group" on:mousedown={onHandleMouseDown}>
<div
class="h-px bg-transparent group-hover:bg-black/10 dark:group-hover:bg-white/10 transition"
/>
<div class="absolute inset-x-0 -top-1.5 -bottom-1.5" />
></div>
<div class="absolute inset-x-0 -top-1.5 -bottom-1.5"></div>
</div>
{/if}
@ -1371,7 +1504,7 @@
: terminalConnecting
? 'bg-yellow-500 animate-pulse'
: 'bg-gray-400'}"
/>
></div>
{/if}
<svg

View file

@ -0,0 +1,331 @@
<script lang="ts">
import { onMount, onDestroy, getContext } from 'svelte';
import {
getDesktopStatus,
startDesktop,
stopDesktop,
getDesktopViewerUrl,
type DesktopStatus
} from '$lib/apis/terminal';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let baseUrl: string;
export let apiKey: string;
export let overlay = false;
let status: DesktopStatus | null = null;
let loading = false;
let starting = false;
let iframeKey = 0;
let iframeEl: HTMLIFrameElement | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
$: isSystemTerminal = baseUrl.includes('/terminals/');
$: serverId = extractServerId(baseUrl);
$: novncPort = status?.novnc_port ?? 6080;
$: viewerUrl = buildViewerUrl();
$: connected = status?.running === true;
function extractServerId(url: string): string | null {
const match = url.match(/\/terminals\/([^/]+)/);
return match ? match[1] : null;
}
function buildViewerUrl(): string {
if (isSystemTerminal && serverId) {
const base = `${WEBUI_API_BASE_URL}/terminals/${serverId}`;
return `${base}/proxy/${novncPort}/vnc.html`;
}
return getDesktopViewerUrl(baseUrl, novncPort);
}
function buildWsPath(): string | null {
if (!isSystemTerminal) return null;
if (!serverId) return null;
const token = localStorage.getItem('token') ?? '';
return `/api/v1/terminals/${serverId}/desktop/ws?token=${encodeURIComponent(token)}`;
}
function getIframeSrc(): string {
const url = viewerUrl;
const params = new URLSearchParams({
autoconnect: 'true',
resize: 'scale'
});
const wsPath = buildWsPath();
if (wsPath) {
params.set('path', wsPath);
}
return `${url}?${params.toString()}`;
}
async function refreshStatus() {
const result = await getDesktopStatus(baseUrl, apiKey);
status = result;
}
async function ensureRunning() {
if (status?.running) return;
starting = true;
const result = await startDesktop(baseUrl, apiKey);
if (result) {
status = result;
} else {
await refreshStatus();
}
starting = false;
}
async function toggleDesktop() {
if (loading) return;
loading = true;
try {
if (status?.running) {
await stopDesktop(baseUrl, apiKey);
status = { running: false };
} else {
await ensureRunning();
}
} finally {
loading = false;
}
}
function refresh() {
iframeKey += 1;
}
function openExternal() {
const url = buildExternalUrl();
window.open(url, '_blank', 'noopener,noreferrer');
}
function buildExternalUrl(): string {
if (isSystemTerminal && serverId) {
const token = localStorage.getItem('token') ?? '';
const base = `${WEBUI_API_BASE_URL}/terminals/${serverId}`;
return `${base}/proxy/${novncPort}/vnc.html?autoconnect=true&resize=scale&path=/api/v1/terminals/${serverId}/desktop/ws?token=${encodeURIComponent(token)}`;
}
return `${getDesktopViewerUrl(baseUrl, novncPort)}?autoconnect=true&resize=scale`;
}
async function goFullscreen() {
iframeEl?.requestFullscreen?.();
}
function startPolling() {
stopPolling();
pollTimer = setInterval(refreshStatus, 10000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
onMount(async () => {
await refreshStatus();
if (!status?.running) {
await ensureRunning();
}
startPolling();
});
onDestroy(() => {
stopPolling();
});
</script>
<div class="flex flex-col h-full min-h-0">
<!-- Toolbar -->
<div
class="flex items-center gap-1 px-1.5 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 shrink-0"
>
<!-- Status indicator -->
<div class="flex items-center gap-1.5 px-1">
<div
class="w-1.5 h-1.5 rounded-full transition-colors {connected
? 'bg-emerald-500'
: starting
? 'bg-yellow-500 animate-pulse'
: 'bg-gray-400'}"
></div>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
{#if starting}
{$i18n.t('Starting...')}
{:else if connected}
{$i18n.t('Connected')}
{:else}
{$i18n.t('Stopped')}
{/if}
</span>
</div>
<div class="flex-1"></div>
<!-- Start/Stop -->
<Tooltip content={connected ? $i18n.t('Stop desktop') : $i18n.t('Start desktop')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={toggleDesktop}
disabled={loading}
aria-label={connected ? $i18n.t('Stop desktop') : $i18n.t('Start desktop')}
>
{#if connected}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4.5 2A1.5 1.5 0 0 0 3 3.5v13A1.5 1.5 0 0 0 4.5 18h11a1.5 1.5 0 0 0 1.5-1.5V7.621a1.5 1.5 0 0 0-.44-1.06l-4.12-4.122A1.5 1.5 0 0 0 11.378 2H4.5Zm2.25 8.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Z"
clip-rule="evenodd"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z"
/>
</svg>
{/if}
</button>
</Tooltip>
<!-- Refresh -->
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Open in new tab -->
<Tooltip content={$i18n.t('Open in new tab')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={openExternal}
aria-label={$i18n.t('Open in new tab')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- Fullscreen -->
<Tooltip content={$i18n.t('Fullscreen')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={goFullscreen}
aria-label={$i18n.t('Fullscreen')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22Zm13.44 0a.75.75 0 0 1 1.06 1.06L14.56 6.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75v-4.5a.75.75 0 0 1 1.5 0v2.69l2.97-2.97ZM3.28 17.78a.75.75 0 0 0 1.06 1.06L6.5 14.56v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-2.16 2.28Zm13.44 1.06a.75.75 0 0 0 1.06-1.06L14.56 14.56v2.69a.75.75 0 0 1-1.5 0v-4.5c0-.414.336-.75.75-.75h4.5a.75.75 0 0 1 0 1.5h-2.69l2.97 2.97a.75.75 0 0 1-.87 1.37Z"
/>
</svg>
</button>
</Tooltip>
</div>
<!-- Starting overlay -->
{#if starting}
<div class="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center gap-2 text-gray-500 dark:text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5 animate-spin"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
<span class="text-xs">{$i18n.t('Starting virtual desktop...')}</span>
</div>
</div>
{:else if !connected}
<!-- Stopped state -->
<div class="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<button
class="flex flex-col items-center gap-2 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={toggleDesktop}
disabled={loading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z"
clip-rule="evenodd"
/>
</svg>
<span class="text-xs font-medium">{$i18n.t('Start Desktop')}</span>
</button>
</div>
{:else}
<!-- noVNC iframe -->
<div class="flex-1 min-h-0 relative">
{#if overlay}
<div class="absolute inset-0 z-10"></div>
{/if}
{#key iframeKey}
<iframe
bind:this={iframeEl}
src={getIframeSrc()}
title="Remote Desktop"
class="w-full h-full border-0 bg-black"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
></iframe>
{/key}
</div>
{/if}
</div>