mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-06 02:48:04 +00:00
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:
parent
b10c70cfcf
commit
9d86ac8186
6 changed files with 1253 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
433
backend/open_webui/test/apps/webui/routers/test_terminals.py
Normal file
433
backend/open_webui/test/apps/webui/routers/test_terminals.py
Normal 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
|
||||
135
src/lib/apis/terminal/desktop.test.ts
Normal file
135
src/lib/apis/terminal/desktop.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
331
src/lib/components/chat/FileNav/DesktopViewer.svelte
Normal file
331
src/lib/components/chat/FileNav/DesktopViewer.svelte
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue