synapse-sdk 1.0.0a98__py3-none-any.whl → 1.0.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of synapse-sdk might be problematic. Click here for more details.
- synapse_sdk/cli/__init__.py +139 -84
- synapse_sdk/cli/code_server.py +169 -0
- synapse_sdk/cli/config.py +105 -4
- synapse_sdk/cli/devtools.py +54 -34
- synapse_sdk/clients/base.py +3 -4
- synapse_sdk/devtools/server.py +24 -791
- synapse_sdk/devtools/streamlit_app/__init__.py +5 -0
- synapse_sdk/devtools/streamlit_app/app.py +128 -0
- synapse_sdk/devtools/streamlit_app/services/__init__.py +11 -0
- synapse_sdk/devtools/streamlit_app/services/job_service.py +233 -0
- synapse_sdk/devtools/streamlit_app/services/plugin_service.py +236 -0
- synapse_sdk/devtools/streamlit_app/services/serve_service.py +95 -0
- synapse_sdk/devtools/streamlit_app/ui/__init__.py +15 -0
- synapse_sdk/devtools/streamlit_app/ui/config_tab.py +76 -0
- synapse_sdk/devtools/streamlit_app/ui/deployment_tab.py +66 -0
- synapse_sdk/devtools/streamlit_app/ui/http_tab.py +125 -0
- synapse_sdk/devtools/streamlit_app/ui/jobs_tab.py +573 -0
- synapse_sdk/devtools/streamlit_app/ui/serve_tab.py +346 -0
- synapse_sdk/devtools/streamlit_app/ui/status_bar.py +118 -0
- synapse_sdk/devtools/streamlit_app/utils/__init__.py +40 -0
- synapse_sdk/devtools/streamlit_app/utils/json_viewer.py +197 -0
- synapse_sdk/devtools/streamlit_app/utils/log_formatter.py +38 -0
- synapse_sdk/devtools/streamlit_app/utils/styles.py +241 -0
- synapse_sdk/devtools/streamlit_app/utils/ui_components.py +289 -0
- synapse_sdk/devtools/streamlit_app.py +10 -0
- synapse_sdk/plugins/categories/upload/actions/upload.py +2 -1
- synapse_sdk/utils/converters/coco/from_dm.py +2 -2
- synapse_sdk/utils/converters/dm/__init__.py +0 -1
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/METADATA +4 -6
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/RECORD +34 -45
- synapse_sdk/devtools/models.py +0 -55
- synapse_sdk/devtools/utils.py +0 -52
- synapse_sdk/devtools/web/.gitignore +0 -2
- synapse_sdk/devtools/web/README.md +0 -34
- synapse_sdk/devtools/web/dist/index.html +0 -17
- synapse_sdk/devtools/web/index.html +0 -16
- synapse_sdk/devtools/web/jsconfig.json +0 -15
- synapse_sdk/devtools/web/package-lock.json +0 -2609
- synapse_sdk/devtools/web/package.json +0 -27
- synapse_sdk/devtools/web/pnpm-lock.yaml +0 -1055
- synapse_sdk/devtools/web/src/App.jsx +0 -14
- synapse_sdk/devtools/web/src/App.module.css +0 -33
- synapse_sdk/devtools/web/src/assets/favicon.ico +0 -0
- synapse_sdk/devtools/web/src/components/Breadcrumbs.jsx +0 -42
- synapse_sdk/devtools/web/src/components/Layout.jsx +0 -12
- synapse_sdk/devtools/web/src/components/LogViewer.jsx +0 -280
- synapse_sdk/devtools/web/src/components/MessageViewer.jsx +0 -150
- synapse_sdk/devtools/web/src/components/NavigationSidebar.jsx +0 -128
- synapse_sdk/devtools/web/src/components/ServerStatusBar.jsx +0 -245
- synapse_sdk/devtools/web/src/components/icons.jsx +0 -325
- synapse_sdk/devtools/web/src/index.css +0 -470
- synapse_sdk/devtools/web/src/index.jsx +0 -15
- synapse_sdk/devtools/web/src/logo.svg +0 -1
- synapse_sdk/devtools/web/src/router.jsx +0 -34
- synapse_sdk/devtools/web/src/utils/api.js +0 -442
- synapse_sdk/devtools/web/src/views/ApplicationDetailView.jsx +0 -241
- synapse_sdk/devtools/web/src/views/ApplicationsView.jsx +0 -224
- synapse_sdk/devtools/web/src/views/HomeView.jsx +0 -197
- synapse_sdk/devtools/web/src/views/JobDetailView.jsx +0 -310
- synapse_sdk/devtools/web/src/views/PluginView.jsx +0 -914
- synapse_sdk/devtools/web/vite.config.js +0 -13
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/WHEEL +0 -0
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/entry_points.txt +0 -0
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/licenses/LICENSE +0 -0
- {synapse_sdk-1.0.0a98.dist-info → synapse_sdk-1.0.0b2.dist-info}/top_level.txt +0 -0
synapse_sdk/devtools/server.py
CHANGED
|
@@ -1,808 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Dict, List, Optional
|
|
1
|
+
"""
|
|
2
|
+
Legacy server module - DEPRECATED
|
|
3
|
+
|
|
4
|
+
This module is kept for backwards compatibility only.
|
|
5
|
+
The devtools now use Streamlit exclusively.
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import yaml
|
|
12
|
-
from fastapi import FastAPI, HTTPException, Request, WebSocket
|
|
13
|
-
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
-
from fastapi.responses import StreamingResponse
|
|
15
|
-
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
All functionality has been moved to streamlit_app.py
|
|
8
|
+
"""
|
|
16
9
|
|
|
17
|
-
|
|
18
|
-
from
|
|
19
|
-
from synapse_sdk.clients.backend import BackendClient
|
|
20
|
-
from synapse_sdk.clients.exceptions import ClientError
|
|
21
|
-
from synapse_sdk.devtools.config import get_backend_config, load_devtools_config
|
|
22
|
-
from synapse_sdk.devtools.models import ConfigResponse, ConfigUpdateRequest
|
|
23
|
-
from synapse_sdk.devtools.utils import get_display_host
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
24
12
|
|
|
25
13
|
logger = logging.getLogger(__name__)
|
|
26
14
|
|
|
27
15
|
|
|
28
16
|
class DevtoolsServer:
|
|
17
|
+
"""Legacy DevtoolsServer class - DEPRECATED
|
|
18
|
+
|
|
19
|
+
This class is kept only for backwards compatibility.
|
|
20
|
+
Use streamlit_app.py instead.
|
|
21
|
+
"""
|
|
22
|
+
|
|
29
23
|
def __init__(self, host: str = '0.0.0.0', port: int = 8080, plugin_directory: str = None):
|
|
24
|
+
logger.warning("DevtoolsServer is deprecated. Use 'synapse devtools' command which runs Streamlit instead.")
|
|
30
25
|
self.host = host
|
|
31
26
|
self.port = port
|
|
32
|
-
|
|
33
|
-
self.agent_id = None
|
|
34
|
-
self.plugin_code = None
|
|
35
27
|
self.plugin_directory = Path(plugin_directory) if plugin_directory else Path.cwd()
|
|
36
|
-
self.websocket_connections: List[WebSocket] = []
|
|
37
|
-
self.app = FastAPI(title='Synapse Devtools', version='1.0.0', lifespan=self.lifespan)
|
|
38
|
-
self._cached_validation_result = None # Cache validation result from startup
|
|
39
|
-
|
|
40
|
-
self.web_build_dir = Path(__file__).parent / 'web' / 'dist'
|
|
41
|
-
self.web_public_dir = Path(__file__).parent / 'web' / 'public'
|
|
42
|
-
|
|
43
|
-
self.setup_middleware()
|
|
44
|
-
self.setup_static_files()
|
|
45
|
-
self.setup_routes()
|
|
46
|
-
|
|
47
|
-
# Initialize clients if config is available
|
|
48
|
-
self.backend_client = self._init_backend_client()
|
|
49
|
-
self.agent_client = self._init_agent_client()
|
|
50
|
-
|
|
51
|
-
@asynccontextmanager
|
|
52
|
-
async def lifespan(self, app: FastAPI):
|
|
53
|
-
# Startup
|
|
54
|
-
logger.info('DevTools server starting up...')
|
|
55
|
-
yield
|
|
56
|
-
# Shutdown
|
|
57
|
-
logger.info('DevTools server shutting down...')
|
|
58
|
-
# Close all WebSocket connections
|
|
59
|
-
if self.websocket_connections:
|
|
60
|
-
logger.info(f'Closing {len(self.websocket_connections)} WebSocket connections')
|
|
61
|
-
for connection in self.websocket_connections[:]:
|
|
62
|
-
try:
|
|
63
|
-
await connection.close(code=1000, reason='Server shutdown')
|
|
64
|
-
except Exception as e:
|
|
65
|
-
logger.error(f'Error closing WebSocket connection: {e}')
|
|
66
|
-
self.websocket_connections.clear()
|
|
67
|
-
logger.info('DevTools server shutdown complete')
|
|
68
|
-
|
|
69
|
-
def _init_backend_client(self) -> Optional[BackendClient]:
|
|
70
|
-
config = get_backend_config()
|
|
71
|
-
if config:
|
|
72
|
-
return BackendClient(config['host'], access_token=config['token'])
|
|
73
|
-
return None
|
|
74
|
-
|
|
75
|
-
def _init_agent_client(self) -> Optional[AgentClient]:
|
|
76
|
-
devtools_config = load_devtools_config()
|
|
77
|
-
agent_config = devtools_config.get('agent', {})
|
|
78
|
-
if agent_config and 'url' in agent_config:
|
|
79
|
-
self.agent_id = agent_config['id']
|
|
80
|
-
# Use shorter timeouts for better UX in devtools
|
|
81
|
-
timeout = {
|
|
82
|
-
'connect': 3, # 3 second connection timeout
|
|
83
|
-
'read': 10, # 10 second read timeout
|
|
84
|
-
}
|
|
85
|
-
return AgentClient(agent_config['url'], agent_config.get('token'), timeout=timeout)
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
def _validate_plugin_config(self) -> Dict:
|
|
89
|
-
"""Validate plugin config.yaml in the plugin directory"""
|
|
90
|
-
|
|
91
|
-
return {'valid': True, 'errors': None, 'schema_version': 'unknown', 'validated_fields': 0}
|
|
92
|
-
|
|
93
|
-
async def _publish_plugin_internal(self, host: str, access_token: str, debug: bool, source_path: Path) -> Dict:
|
|
94
|
-
"""Internal method to publish plugin using CLI logic"""
|
|
95
|
-
import os
|
|
96
|
-
|
|
97
|
-
original_cwd = os.getcwd()
|
|
98
|
-
try:
|
|
99
|
-
os.chdir(str(source_path))
|
|
100
|
-
|
|
101
|
-
debug_modules = os.getenv('SYNAPSE_DEBUG_MODULES', '')
|
|
102
|
-
plugin_release = _publish(host, access_token, debug, debug_modules)
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
'success': True,
|
|
106
|
-
'message': f'Successfully published "{plugin_release.name}" '
|
|
107
|
-
f'({plugin_release.code}) to synapse backend!',
|
|
108
|
-
'plugin_code': plugin_release.code,
|
|
109
|
-
'version': plugin_release.version,
|
|
110
|
-
'name': plugin_release.name,
|
|
111
|
-
'result': '',
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
finally:
|
|
115
|
-
os.chdir(original_cwd)
|
|
116
|
-
|
|
117
|
-
async def _execute_plugin_http_request(
|
|
118
|
-
self,
|
|
119
|
-
action: str,
|
|
120
|
-
params: Dict,
|
|
121
|
-
debug: bool,
|
|
122
|
-
access_token: str,
|
|
123
|
-
) -> Dict:
|
|
124
|
-
"""Execute an HTTP request to the plugin endpoint"""
|
|
125
|
-
import asyncio
|
|
126
|
-
import time
|
|
127
|
-
|
|
128
|
-
import aiohttp
|
|
129
|
-
|
|
130
|
-
plugin_url = f'{self.backend_client.base_url}/plugins/{self.plugin_code}/run/'
|
|
131
|
-
|
|
132
|
-
headers = {
|
|
133
|
-
'Accept': 'application/json; indent=4',
|
|
134
|
-
'Content-Type': 'application/json',
|
|
135
|
-
'Synapse-Access-Token': f'Token {access_token}',
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
payload = {'agent': self.agent_id, 'action': action, 'params': params, 'debug': debug}
|
|
139
|
-
|
|
140
|
-
start_time = time.time()
|
|
141
|
-
|
|
142
|
-
try:
|
|
143
|
-
timeout = aiohttp.ClientTimeout(total=30) # 30 second timeout
|
|
144
|
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
145
|
-
async with session.post(plugin_url, json=payload, headers=headers) as response:
|
|
146
|
-
execution_time = int((time.time() - start_time) * 1000)
|
|
147
|
-
|
|
148
|
-
response_text = await response.text()
|
|
149
|
-
|
|
150
|
-
# Try to parse as JSON, fallback to text
|
|
151
|
-
try:
|
|
152
|
-
response_data = await response.json()
|
|
153
|
-
except Exception:
|
|
154
|
-
response_data = response_text
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
'success': response.status < 400,
|
|
158
|
-
'status_code': response.status,
|
|
159
|
-
'response_data': response_data,
|
|
160
|
-
'execution_time': execution_time,
|
|
161
|
-
'url': plugin_url,
|
|
162
|
-
'method': 'POST',
|
|
163
|
-
'headers': headers,
|
|
164
|
-
'payload': payload,
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
except asyncio.TimeoutError:
|
|
168
|
-
execution_time = int((time.time() - start_time) * 1000)
|
|
169
|
-
return {
|
|
170
|
-
'success': False,
|
|
171
|
-
'status_code': 408,
|
|
172
|
-
'error': 'Request timeout',
|
|
173
|
-
'execution_time': execution_time,
|
|
174
|
-
'url': plugin_url,
|
|
175
|
-
}
|
|
176
|
-
except Exception as e:
|
|
177
|
-
execution_time = int((time.time() - start_time) * 1000)
|
|
178
|
-
return {
|
|
179
|
-
'success': False,
|
|
180
|
-
'status_code': 500,
|
|
181
|
-
'error': str(e),
|
|
182
|
-
'execution_time': execution_time,
|
|
183
|
-
'url': plugin_url,
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
def _get_action_example_params(self, action: str) -> Dict:
|
|
187
|
-
"""Get pre-filled example parameters for different actions based on test.http"""
|
|
188
|
-
examples = {
|
|
189
|
-
'tune': {
|
|
190
|
-
'num_cpus': 2,
|
|
191
|
-
'num_gpus': 1,
|
|
192
|
-
'name': 'tune-test-example',
|
|
193
|
-
'description': 'Tuning example',
|
|
194
|
-
'experiment': 37,
|
|
195
|
-
'dataset': 55,
|
|
196
|
-
'checkpoint': 163,
|
|
197
|
-
'tune_config': {'metric': 'metrics/mAP50-95(B)', 'mode': 'max', 'num_samples': 5},
|
|
198
|
-
'hyperparameter': [
|
|
199
|
-
{'name': 'learning_rate', 'type': 'loguniform', 'min': 1e-4, 'max': 1e-1},
|
|
200
|
-
{'name': 'batch_size', 'type': 'choice', 'options': [8, 16, 32]},
|
|
201
|
-
{'name': 'epochs', 'type': 'randint', 'min': 1, 'max': 2},
|
|
202
|
-
],
|
|
203
|
-
},
|
|
204
|
-
'deployment': {'num_cpus': 4, 'num_gpus': 1},
|
|
205
|
-
'train': {
|
|
206
|
-
'num_cpus': 2,
|
|
207
|
-
'num_gpus': 1,
|
|
208
|
-
'name': 'train-test-example',
|
|
209
|
-
'description': 'Training example',
|
|
210
|
-
'experiment': 32,
|
|
211
|
-
'dataset': 55,
|
|
212
|
-
'checkpoint': 8,
|
|
213
|
-
'hyperparameter': {'epochs': 10, 'batch_size': 8, 'learning_rate': 0.001, 'imgsz': 640},
|
|
214
|
-
},
|
|
215
|
-
'gradio': {'num_cpus': 4},
|
|
216
|
-
'inference': {'num_cpus': 2, 'num_gpus': 1},
|
|
217
|
-
'test': {'num_cpus': 1},
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return examples.get(action, {})
|
|
221
|
-
|
|
222
|
-
def setup_middleware(self):
|
|
223
|
-
self.app.add_middleware(
|
|
224
|
-
CORSMiddleware,
|
|
225
|
-
allow_origins=['*'],
|
|
226
|
-
allow_credentials=True,
|
|
227
|
-
allow_methods=['*'],
|
|
228
|
-
allow_headers=['*'],
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
def setup_static_files(self):
|
|
232
|
-
# Serve built Vue.js assets from web/dist/
|
|
233
|
-
if self.web_build_dir.exists():
|
|
234
|
-
# Serve Vue.js built assets
|
|
235
|
-
assets_dir = self.web_build_dir / 'assets'
|
|
236
|
-
if assets_dir.exists():
|
|
237
|
-
self.app.mount('/assets', StaticFiles(directory=assets_dir), name='assets')
|
|
238
|
-
else:
|
|
239
|
-
logger.warning('Vue.js dist directory not found, devtools may not work properly')
|
|
240
|
-
|
|
241
|
-
# Serve public files (like logo.png) during development
|
|
242
|
-
if self.web_public_dir.exists():
|
|
243
|
-
self.app.mount('/public', StaticFiles(directory=self.web_public_dir), name='public')
|
|
244
|
-
|
|
245
|
-
def _inject_env_vars(self, html_content: str) -> str:
|
|
246
|
-
"""Inject environment variables into HTML"""
|
|
247
|
-
# Use display host for frontend access (resolves 0.0.0.0 to actual IP)
|
|
248
|
-
display_host = get_display_host(self.host)
|
|
249
|
-
env_script = f"""<script>
|
|
250
|
-
window.VITE_API_PORT = {self.port};
|
|
251
|
-
window.VITE_API_HOST = '{display_host}';
|
|
252
|
-
</script>"""
|
|
253
|
-
|
|
254
|
-
# Insert before closing head tag
|
|
255
|
-
return html_content.replace('</head>', f'{env_script}</head>')
|
|
256
|
-
|
|
257
|
-
def setup_routes(self):
|
|
258
|
-
@self.app.get('/')
|
|
259
|
-
async def read_root():
|
|
260
|
-
index_file = self.web_build_dir / 'index.html'
|
|
261
|
-
|
|
262
|
-
if index_file.exists():
|
|
263
|
-
# Read and modify HTML content to inject environment variables
|
|
264
|
-
with open(index_file, 'r') as f:
|
|
265
|
-
html_content = f.read()
|
|
266
|
-
|
|
267
|
-
modified_html = self._inject_env_vars(html_content)
|
|
268
|
-
|
|
269
|
-
from fastapi.responses import HTMLResponse
|
|
270
|
-
|
|
271
|
-
return HTMLResponse(content=modified_html)
|
|
272
|
-
return {'message': 'Synapse Devtools API', 'docs': '/docs'}
|
|
273
|
-
|
|
274
|
-
@self.app.get('/status')
|
|
275
|
-
async def get_status():
|
|
276
|
-
from synapse_sdk.devtools.config import load_devtools_config
|
|
277
|
-
|
|
278
|
-
backend_config = get_backend_config()
|
|
279
|
-
devtools_config = load_devtools_config()
|
|
280
|
-
agent_config = devtools_config.get('agent', {})
|
|
281
|
-
|
|
282
|
-
status = {
|
|
283
|
-
'backend': {
|
|
284
|
-
'host': self.backend_client.base_url,
|
|
285
|
-
'status': 'configured' if backend_config else 'not_configured',
|
|
286
|
-
},
|
|
287
|
-
'agent': {
|
|
288
|
-
'id': agent_config.get('id'),
|
|
289
|
-
'name': agent_config.get('name'),
|
|
290
|
-
},
|
|
291
|
-
'devtools': {'version': version('synapse-sdk')},
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return status
|
|
295
|
-
|
|
296
|
-
@self.app.get('/auth/token')
|
|
297
|
-
async def get_auth_token():
|
|
298
|
-
"""Get the current authentication token from configuration"""
|
|
299
|
-
backend_config = get_backend_config()
|
|
300
|
-
|
|
301
|
-
if backend_config and 'token' in backend_config:
|
|
302
|
-
return {'token': backend_config['token'], 'host': backend_config['host']}
|
|
303
|
-
|
|
304
|
-
return {'token': None, 'host': None}
|
|
305
|
-
|
|
306
|
-
@self.app.get('/jobs')
|
|
307
|
-
async def get_jobs():
|
|
308
|
-
"""Get the list of jobs from the backend"""
|
|
309
|
-
if not self.agent_client:
|
|
310
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
311
|
-
|
|
312
|
-
try:
|
|
313
|
-
jobs = self.agent_client.list_jobs()
|
|
314
|
-
jobs.sort(key=lambda job: job.get('start_time', 0), reverse=True)
|
|
315
|
-
return jobs
|
|
316
|
-
except ClientError as e:
|
|
317
|
-
logger.warning(f'Agent client error while fetching jobs: {e.reason}')
|
|
318
|
-
if e.status in [408, 503]:
|
|
319
|
-
raise HTTPException(status_code=503, detail=f'Agent unavailable: {e.reason}')
|
|
320
|
-
else:
|
|
321
|
-
raise HTTPException(status_code=e.status, detail=e.reason)
|
|
322
|
-
except Exception as e:
|
|
323
|
-
logger.error(f'Unexpected error fetching jobs: {e}')
|
|
324
|
-
raise HTTPException(status_code=500, detail='Failed to fetch jobs')
|
|
325
|
-
|
|
326
|
-
@self.app.get('/jobs/{job_id}')
|
|
327
|
-
async def get_job(job_id: str):
|
|
328
|
-
"""Get details of a specific job by ID"""
|
|
329
|
-
if not self.agent_client:
|
|
330
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
331
|
-
|
|
332
|
-
try:
|
|
333
|
-
job = self.agent_client.get_job(job_id)
|
|
334
|
-
return job
|
|
335
|
-
except Exception as e:
|
|
336
|
-
logger.error(f'Failed to fetch job {job_id}: {e}')
|
|
337
|
-
raise HTTPException(status_code=500, detail=f'Failed to fetch job {job_id}')
|
|
338
|
-
|
|
339
|
-
@self.app.get('/jobs/{job_id}/logs')
|
|
340
|
-
async def get_job_logs(job_id: str, request: Request):
|
|
341
|
-
"""Get logs for a specific job by ID"""
|
|
342
|
-
if not self.agent_client:
|
|
343
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
344
|
-
|
|
345
|
-
async def log_generator():
|
|
346
|
-
import asyncio
|
|
347
|
-
|
|
348
|
-
try:
|
|
349
|
-
# Run the synchronous generator in a thread to avoid blocking
|
|
350
|
-
loop = asyncio.get_event_loop()
|
|
351
|
-
log_iter = self.agent_client.tail_job_logs(job_id, stream_timeout=3)
|
|
352
|
-
|
|
353
|
-
while True:
|
|
354
|
-
# Check if client disconnected before getting next log
|
|
355
|
-
if await request.is_disconnected():
|
|
356
|
-
logger.info(f'Client disconnected, stopping log stream for job {job_id}')
|
|
357
|
-
break
|
|
358
|
-
|
|
359
|
-
try:
|
|
360
|
-
# Get next log line with timeout to allow periodic disconnect checks
|
|
361
|
-
log_line = await loop.run_in_executor(None, lambda: next(log_iter, None))
|
|
362
|
-
|
|
363
|
-
if log_line is None:
|
|
364
|
-
break
|
|
365
|
-
|
|
366
|
-
# Check again before yielding
|
|
367
|
-
if await request.is_disconnected():
|
|
368
|
-
logger.info(f'Client disconnected before yield, stopping log stream for job {job_id}')
|
|
369
|
-
break
|
|
370
|
-
|
|
371
|
-
# Convert plain text to SSE format
|
|
372
|
-
if log_line.strip():
|
|
373
|
-
yield f'data: {log_line.strip()}\n\n'
|
|
374
|
-
|
|
375
|
-
except StopIteration:
|
|
376
|
-
break
|
|
377
|
-
|
|
378
|
-
except ClientError as e:
|
|
379
|
-
logger.warning(f'Agent client error in log streaming for job {job_id}: {e.reason}')
|
|
380
|
-
if e.status == 408:
|
|
381
|
-
yield 'data: Log stream timeout - agent may be unresponsive\n\n'
|
|
382
|
-
elif e.status == 503:
|
|
383
|
-
yield 'data: Agent connection error - service may be unavailable\n\n'
|
|
384
|
-
else:
|
|
385
|
-
yield f'data: Agent error: {e.reason}\n\n'
|
|
386
|
-
except Exception as e:
|
|
387
|
-
logger.error(f'Unexpected error in log streaming for job {job_id}: {e}')
|
|
388
|
-
yield f'data: Unexpected error: {str(e)}\n\n'
|
|
389
|
-
finally:
|
|
390
|
-
logger.info(f'Log streaming ended for job {job_id}')
|
|
391
|
-
|
|
392
|
-
try:
|
|
393
|
-
return StreamingResponse(log_generator(), media_type='text/event-stream')
|
|
394
|
-
except Exception as e:
|
|
395
|
-
logger.error(f'Failed to setup log stream for job {job_id}: {e}')
|
|
396
|
-
raise HTTPException(status_code=500, detail=f'Failed to setup log stream for job {job_id}')
|
|
397
|
-
|
|
398
|
-
@self.app.get('/serve_applications')
|
|
399
|
-
async def get_serve_applications(request: Request):
|
|
400
|
-
"""Get the list of serve applications from the agent or serve SPA"""
|
|
401
|
-
# Check if this is an API request (AJAX call) vs browser navigation
|
|
402
|
-
accept_header = request.headers.get('accept', '')
|
|
403
|
-
|
|
404
|
-
# If browser navigation (HTML request), serve the SPA
|
|
405
|
-
if 'text/html' in accept_header and 'application/json' not in accept_header:
|
|
406
|
-
index_file = self.web_build_dir / 'index.html'
|
|
407
|
-
if index_file.exists():
|
|
408
|
-
# Read and modify HTML content to inject environment variables
|
|
409
|
-
with open(index_file, 'r') as f:
|
|
410
|
-
html_content = f.read()
|
|
411
|
-
|
|
412
|
-
modified_html = self._inject_env_vars(html_content)
|
|
413
|
-
|
|
414
|
-
from fastapi.responses import HTMLResponse
|
|
415
|
-
|
|
416
|
-
return HTMLResponse(content=modified_html)
|
|
417
|
-
return {'message': 'Frontend not built'}
|
|
418
|
-
|
|
419
|
-
# Otherwise, handle as API request
|
|
420
|
-
if not self.agent_client:
|
|
421
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
422
|
-
|
|
423
|
-
try:
|
|
424
|
-
applications = self.agent_client.list_serve_applications()
|
|
425
|
-
# Sort by creation time if available, otherwise alphabetically by name
|
|
426
|
-
if applications:
|
|
427
|
-
applications.sort(key=lambda app: app.get('created_at', app.get('name', '')), reverse=True)
|
|
428
|
-
return applications
|
|
429
|
-
except ClientError as e:
|
|
430
|
-
logger.warning(f'Agent client error while fetching applications: {e.reason}')
|
|
431
|
-
if e.status in [408, 503]:
|
|
432
|
-
raise HTTPException(status_code=503, detail=f'Agent unavailable: {e.reason}')
|
|
433
|
-
else:
|
|
434
|
-
raise HTTPException(status_code=e.status, detail=e.reason)
|
|
435
|
-
except Exception as e:
|
|
436
|
-
logger.error(f'Unexpected error fetching applications: {e}')
|
|
437
|
-
raise HTTPException(status_code=500, detail='Failed to fetch applications')
|
|
438
|
-
|
|
439
|
-
@self.app.get('/serve_applications/{app_id}')
|
|
440
|
-
async def get_serve_application(app_id: str):
|
|
441
|
-
"""Get details of a specific application by ID"""
|
|
442
|
-
if not self.agent_client:
|
|
443
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
444
|
-
|
|
445
|
-
try:
|
|
446
|
-
application = self.agent_client.get_serve_application(app_id)
|
|
447
|
-
return application
|
|
448
|
-
except Exception as e:
|
|
449
|
-
logger.error(f'Failed to fetch application {app_id}: {e}')
|
|
450
|
-
raise HTTPException(status_code=500, detail=f'Failed to fetch application {app_id}')
|
|
451
|
-
|
|
452
|
-
@self.app.delete('/serve_applications/{app_id}')
|
|
453
|
-
async def delete_serve_application(app_id: str):
|
|
454
|
-
"""Delete a serve application by ID"""
|
|
455
|
-
if not self.agent_client:
|
|
456
|
-
raise HTTPException(status_code=503, detail='Agent client not configured')
|
|
457
|
-
|
|
458
|
-
try:
|
|
459
|
-
result = self.agent_client.delete_serve_application(app_id)
|
|
460
|
-
return {'message': f'Application {app_id} deleted successfully', 'result': result}
|
|
461
|
-
except Exception as e:
|
|
462
|
-
logger.error(f'Failed to delete application {app_id}: {e}')
|
|
463
|
-
raise HTTPException(status_code=500, detail=f'Failed to delete application {app_id}')
|
|
464
|
-
|
|
465
|
-
@self.app.get('/health/backend')
|
|
466
|
-
async def check_backend_health():
|
|
467
|
-
"""Check backend health status"""
|
|
468
|
-
if not self.backend_client:
|
|
469
|
-
return {'status': 'not_configured', 'error': 'Backend client not configured', 'lastCheck': None}
|
|
470
|
-
|
|
471
|
-
import time
|
|
472
|
-
|
|
473
|
-
start_time = time.time()
|
|
474
|
-
|
|
475
|
-
try:
|
|
476
|
-
# Use the backend client to check health with shorter timeout
|
|
477
|
-
response = requests.get(f'{self.backend_client.base_url}/health', timeout=3)
|
|
478
|
-
latency = int((time.time() - start_time) * 1000)
|
|
479
|
-
|
|
480
|
-
if response.status_code == 200:
|
|
481
|
-
return {
|
|
482
|
-
'status': 'healthy',
|
|
483
|
-
'latency': latency,
|
|
484
|
-
'lastCheck': time.time(),
|
|
485
|
-
'url': f'{self.backend_client.base_url}',
|
|
486
|
-
'httpStatus': response.status_code,
|
|
487
|
-
}
|
|
488
|
-
else:
|
|
489
|
-
status_map = {
|
|
490
|
-
401: 'auth_error',
|
|
491
|
-
403: 'forbidden',
|
|
492
|
-
404: 'not_found',
|
|
493
|
-
500: 'down',
|
|
494
|
-
502: 'down',
|
|
495
|
-
503: 'down',
|
|
496
|
-
504: 'down',
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return {
|
|
500
|
-
'status': status_map.get(response.status_code, 'down'),
|
|
501
|
-
'lastCheck': time.time(),
|
|
502
|
-
'url': f'{self.backend_client.host}/health',
|
|
503
|
-
'httpStatus': response.status_code,
|
|
504
|
-
'error': f'HTTP {response.status_code}',
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
except requests.exceptions.Timeout:
|
|
508
|
-
return {
|
|
509
|
-
'status': 'timeout',
|
|
510
|
-
'lastCheck': time.time(),
|
|
511
|
-
'url': f'{self.backend_client.base_url}/health',
|
|
512
|
-
'error': 'Request timeout (>3s)',
|
|
513
|
-
}
|
|
514
|
-
except requests.exceptions.RequestException as e:
|
|
515
|
-
return {
|
|
516
|
-
'status': 'down',
|
|
517
|
-
'lastCheck': time.time(),
|
|
518
|
-
'url': f'{self.backend_client.base_url}/health',
|
|
519
|
-
'error': str(e),
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
@self.app.get('/health/agent')
|
|
523
|
-
async def check_agent_health():
|
|
524
|
-
"""Check agent health status"""
|
|
525
|
-
if not self.agent_client:
|
|
526
|
-
return {'status': 'not_configured', 'error': 'Agent client not configured', 'lastCheck': None}
|
|
527
|
-
|
|
528
|
-
import time
|
|
529
|
-
|
|
530
|
-
start_time = time.time()
|
|
531
|
-
|
|
532
|
-
try:
|
|
533
|
-
# Use the agent client to check health with shorter timeout
|
|
534
|
-
response = requests.get(
|
|
535
|
-
f'{self.agent_client.base_url}/health',
|
|
536
|
-
timeout=3,
|
|
537
|
-
headers={'Authorization': f'Token {self.agent_client.agent_token}'},
|
|
538
|
-
)
|
|
539
|
-
latency = int((time.time() - start_time) * 1000)
|
|
540
|
-
|
|
541
|
-
if response.status_code == 200:
|
|
542
|
-
return {
|
|
543
|
-
'status': 'healthy',
|
|
544
|
-
'latency': latency,
|
|
545
|
-
'lastCheck': time.time(),
|
|
546
|
-
'url': f'{self.agent_client.base_url}/health',
|
|
547
|
-
'httpStatus': response.status_code,
|
|
548
|
-
}
|
|
549
|
-
else:
|
|
550
|
-
status_map = {
|
|
551
|
-
401: 'auth_error',
|
|
552
|
-
403: 'forbidden',
|
|
553
|
-
404: 'not_found',
|
|
554
|
-
500: 'down',
|
|
555
|
-
502: 'down',
|
|
556
|
-
503: 'down',
|
|
557
|
-
504: 'down',
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
return {
|
|
561
|
-
'status': status_map.get(response.status_code, 'down'),
|
|
562
|
-
'lastCheck': time.time(),
|
|
563
|
-
'url': f'{self.agent_client.base_url}/health',
|
|
564
|
-
'httpStatus': response.status_code,
|
|
565
|
-
'error': f'HTTP {response.status_code}',
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
except requests.exceptions.Timeout:
|
|
569
|
-
return {
|
|
570
|
-
'status': 'timeout',
|
|
571
|
-
'lastCheck': time.time(),
|
|
572
|
-
'url': f'{self.agent_client.base_url}/health',
|
|
573
|
-
'error': 'Request timeout (>3s)',
|
|
574
|
-
}
|
|
575
|
-
except requests.exceptions.RequestException as e:
|
|
576
|
-
return {
|
|
577
|
-
'status': 'down',
|
|
578
|
-
'lastCheck': time.time(),
|
|
579
|
-
'url': f'{self.agent_client.base_url}/health',
|
|
580
|
-
'error': str(e),
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
@self.app.get('/config')
|
|
584
|
-
async def get_config():
|
|
585
|
-
"""Get the current config.yaml content with validation status"""
|
|
586
|
-
config_path = self.plugin_directory / 'config.yaml'
|
|
587
|
-
|
|
588
|
-
if not config_path.exists():
|
|
589
|
-
raise HTTPException(
|
|
590
|
-
status_code=404,
|
|
591
|
-
detail='config.yaml not found in plugin directory: '
|
|
592
|
-
'{self.plugin_directory}. DevTools server expects to run from within a plugin directory.',
|
|
593
|
-
)
|
|
594
|
-
|
|
595
|
-
try:
|
|
596
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
597
|
-
config_data = yaml.safe_load(f)
|
|
598
|
-
|
|
599
|
-
stat = config_path.stat()
|
|
600
|
-
last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
601
|
-
|
|
602
|
-
validation_result = self._validate_plugin_config()
|
|
603
|
-
|
|
604
|
-
response = ConfigResponse(config=config_data, file_path=str(config_path), last_modified=last_modified)
|
|
605
|
-
response_dict = response.model_dump()
|
|
606
|
-
response_dict['validation'] = validation_result
|
|
607
|
-
|
|
608
|
-
# variables
|
|
609
|
-
self.plugin_code = response_dict['config'].get('code')
|
|
610
|
-
|
|
611
|
-
return response_dict
|
|
612
|
-
except yaml.YAMLError as e:
|
|
613
|
-
raise HTTPException(status_code=422, detail=f'Invalid YAML format: {str(e)}')
|
|
614
|
-
except Exception as e:
|
|
615
|
-
logger.error(f'Failed to read config.yaml: {e}')
|
|
616
|
-
raise HTTPException(status_code=500, detail='Failed to read configuration file')
|
|
617
|
-
|
|
618
|
-
@self.app.put('/config')
|
|
619
|
-
async def update_config(request: ConfigUpdateRequest):
|
|
620
|
-
"""Update the config.yaml file"""
|
|
621
|
-
config_path = self.plugin_directory / 'config.yaml'
|
|
622
|
-
|
|
623
|
-
try:
|
|
624
|
-
# Create backup of existing config
|
|
625
|
-
if config_path.exists():
|
|
626
|
-
backup_path = config_path.with_suffix('.yaml.bak')
|
|
627
|
-
config_path.rename(backup_path)
|
|
628
|
-
|
|
629
|
-
# Write new config
|
|
630
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
631
|
-
yaml.dump(request.config, f, default_flow_style=False, allow_unicode=True, indent=2)
|
|
632
|
-
|
|
633
|
-
# Return updated config
|
|
634
|
-
stat = config_path.stat()
|
|
635
|
-
last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
636
|
-
|
|
637
|
-
return ConfigResponse(config=request.config, file_path=str(config_path), last_modified=last_modified)
|
|
638
|
-
except Exception as e:
|
|
639
|
-
# Restore backup if write failed
|
|
640
|
-
backup_path = config_path.with_suffix('.yaml.bak')
|
|
641
|
-
if backup_path.exists():
|
|
642
|
-
backup_path.rename(config_path)
|
|
643
|
-
|
|
644
|
-
logger.error(f'Failed to update config.yaml: {e}')
|
|
645
|
-
raise HTTPException(status_code=500, detail='Failed to update configuration file')
|
|
646
|
-
|
|
647
|
-
@self.app.post('/config/validate')
|
|
648
|
-
async def validate_config(request: ConfigUpdateRequest):
|
|
649
|
-
"""Validate config.yaml structure using JSON schema"""
|
|
650
|
-
return self._validate_plugin_config()
|
|
651
|
-
|
|
652
|
-
@self.app.post('/plugin/http')
|
|
653
|
-
async def execute_plugin_http(request: Request):
|
|
654
|
-
"""Execute a plugin HTTP request with specified parameters"""
|
|
655
|
-
try:
|
|
656
|
-
body = await request.json()
|
|
657
|
-
|
|
658
|
-
# Extract request parameters
|
|
659
|
-
action = body.get('action')
|
|
660
|
-
params = body.get('params', {})
|
|
661
|
-
debug = body.get('debug', True)
|
|
662
|
-
access_token = body.get('access_token', None)
|
|
663
|
-
|
|
664
|
-
# Validate plugin config before executing
|
|
665
|
-
validation_result = self._validate_plugin_config()
|
|
666
|
-
if not validation_result['valid']:
|
|
667
|
-
return {
|
|
668
|
-
'success': False,
|
|
669
|
-
'error': 'Plugin configuration is invalid',
|
|
670
|
-
'validation_errors': validation_result['errors'],
|
|
671
|
-
'message': 'Please fix config.yaml before executing HTTP requests',
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
# Execute the HTTP request
|
|
675
|
-
result = await self._execute_plugin_http_request(
|
|
676
|
-
action=action, params=params, debug=debug, access_token=access_token
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
return result
|
|
680
|
-
|
|
681
|
-
except HTTPException:
|
|
682
|
-
raise
|
|
683
|
-
except Exception as e:
|
|
684
|
-
logger.error(f'Failed to execute plugin HTTP request: {e}')
|
|
685
|
-
raise HTTPException(status_code=500, detail=f'Failed to execute HTTP request: {str(e)}')
|
|
686
|
-
|
|
687
|
-
@self.app.get('/plugin/http/params/{action}')
|
|
688
|
-
async def get_action_params(action: str):
|
|
689
|
-
"""Get pre-filled example parameters for a specific action"""
|
|
690
|
-
example_params = self._get_action_example_params(action)
|
|
691
|
-
|
|
692
|
-
return {'action': action, 'example_params': example_params, 'has_example': len(example_params) > 0}
|
|
693
|
-
|
|
694
|
-
@self.app.post('/plugin/publish')
|
|
695
|
-
async def publish_plugin(request: Request):
|
|
696
|
-
"""Publish a plugin to the Synapse platform using the same logic as CLI publish"""
|
|
697
|
-
try:
|
|
698
|
-
body = await request.json()
|
|
699
|
-
|
|
700
|
-
# Extract publish configuration
|
|
701
|
-
host = body.get('host')
|
|
702
|
-
access_token = body.get('access_token') # This is the synapse access token
|
|
703
|
-
debug = body.get('debug', True)
|
|
704
|
-
|
|
705
|
-
# Validate required fields
|
|
706
|
-
if not all([host, access_token]):
|
|
707
|
-
raise HTTPException(status_code=400, detail='Missing required fields: host or access_token')
|
|
708
|
-
|
|
709
|
-
# Validate plugin config before publishing
|
|
710
|
-
validation_result = self._validate_plugin_config()
|
|
711
|
-
if not validation_result['valid']:
|
|
712
|
-
return {
|
|
713
|
-
'success': False,
|
|
714
|
-
'error': 'Plugin configuration is invalid',
|
|
715
|
-
'validation_errors': validation_result['errors'],
|
|
716
|
-
'message': 'Please fix config.yaml before publishing',
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
# Use the existing CLI publish logic
|
|
720
|
-
result = await self._publish_plugin_internal(
|
|
721
|
-
host=host, access_token=access_token, debug=debug, source_path=self.plugin_directory
|
|
722
|
-
)
|
|
723
|
-
|
|
724
|
-
return result
|
|
725
|
-
|
|
726
|
-
except HTTPException:
|
|
727
|
-
raise
|
|
728
|
-
except Exception as e:
|
|
729
|
-
logger.error(f'Failed to publish plugin: {e}')
|
|
730
|
-
raise HTTPException(status_code=500, detail=f'Failed to publish plugin: {str(e)}')
|
|
731
|
-
|
|
732
|
-
@self.app.get('/{path:path}')
|
|
733
|
-
async def spa_handler(path: str):
|
|
734
|
-
"""Serve index.html for all non-API routes (SPA routing)"""
|
|
735
|
-
# Don't handle API routes, static assets, or docs
|
|
736
|
-
if (
|
|
737
|
-
path.startswith('api/')
|
|
738
|
-
or path.startswith('docs')
|
|
739
|
-
or path.startswith('redoc')
|
|
740
|
-
or path.startswith('assets/')
|
|
741
|
-
or path.startswith('public/')
|
|
742
|
-
or path.startswith('jobs')
|
|
743
|
-
or path.startswith('health/')
|
|
744
|
-
or path.startswith('config')
|
|
745
|
-
or path.startswith('status')
|
|
746
|
-
or path.startswith('auth/')
|
|
747
|
-
or path.startswith('plugin/')
|
|
748
|
-
or path.endswith('.js')
|
|
749
|
-
or path.endswith('.css')
|
|
750
|
-
or path.endswith('.ico')
|
|
751
|
-
or path.endswith('.png')
|
|
752
|
-
or path.endswith('.svg')
|
|
753
|
-
or path.endswith('.jpg')
|
|
754
|
-
or path.endswith('.jpeg')
|
|
755
|
-
or path.endswith('.woff')
|
|
756
|
-
or path.endswith('.woff2')
|
|
757
|
-
or path.endswith('.ttf')
|
|
758
|
-
):
|
|
759
|
-
raise HTTPException(status_code=404, detail='Not found')
|
|
760
|
-
|
|
761
|
-
index_file = self.web_build_dir / 'index.html'
|
|
762
|
-
|
|
763
|
-
if index_file.exists():
|
|
764
|
-
# Read and modify HTML content to inject environment variables
|
|
765
|
-
with open(index_file, 'r') as f:
|
|
766
|
-
html_content = f.read()
|
|
767
|
-
|
|
768
|
-
modified_html = self._inject_env_vars(html_content)
|
|
769
|
-
|
|
770
|
-
from fastapi.responses import HTMLResponse
|
|
771
|
-
|
|
772
|
-
return HTMLResponse(content=modified_html)
|
|
773
|
-
|
|
774
|
-
# Fallback if no built frontend
|
|
775
|
-
return {'message': 'Synapse Devtools API', 'docs': '/docs', 'path': path}
|
|
776
|
-
|
|
777
|
-
async def broadcast_update(self, data: Dict):
|
|
778
|
-
"""Broadcast updates to all connected WebSocket clients"""
|
|
779
|
-
if self.websocket_connections:
|
|
780
|
-
message = json.dumps(data)
|
|
781
|
-
disconnected_connections = []
|
|
782
|
-
|
|
783
|
-
for connection in self.websocket_connections[:]:
|
|
784
|
-
try:
|
|
785
|
-
await connection.send_text(message)
|
|
786
|
-
except Exception as e:
|
|
787
|
-
logger.error(f'Error sending WebSocket message: {e}')
|
|
788
|
-
disconnected_connections.append(connection)
|
|
789
|
-
|
|
790
|
-
# Remove disconnected connections
|
|
791
|
-
for connection in disconnected_connections:
|
|
792
|
-
if connection in self.websocket_connections:
|
|
793
|
-
self.websocket_connections.remove(connection)
|
|
794
|
-
logger.info('Removed disconnected WebSocket connection')
|
|
795
28
|
|
|
796
29
|
def start_server(self):
|
|
797
|
-
"""
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
url = f'http://{display_host}:{self.port}'
|
|
801
|
-
print(f'Open devtools on your browser: {url}')
|
|
802
|
-
|
|
803
|
-
uvicorn.run(self.app, host=self.host, port=self.port, log_level='warning', access_log=False)
|
|
30
|
+
"""Legacy method - DEPRECATED"""
|
|
31
|
+
logger.error("FastAPI server is no longer supported. Use 'synapse devtools' command to run Streamlit app.")
|
|
32
|
+
raise RuntimeError("FastAPI server is deprecated. Use 'synapse devtools' command to run the Streamlit app.")
|
|
804
33
|
|
|
805
34
|
|
|
806
35
|
def create_devtools_server(host: str = '0.0.0.0', port: int = 8080, plugin_directory: str = None) -> DevtoolsServer:
|
|
807
|
-
"""
|
|
36
|
+
"""Legacy function - DEPRECATED
|
|
37
|
+
|
|
38
|
+
This function is kept only for backwards compatibility.
|
|
39
|
+
Use 'synapse devtools' command instead.
|
|
40
|
+
"""
|
|
808
41
|
return DevtoolsServer(host=host, port=port, plugin_directory=plugin_directory)
|