experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__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 experimaestro might be problematic. Click here for more details.
- experimaestro/__init__.py +12 -5
- experimaestro/cli/__init__.py +239 -126
- experimaestro/cli/filter.py +48 -23
- experimaestro/cli/jobs.py +253 -71
- experimaestro/cli/refactor.py +1 -2
- experimaestro/commandline.py +7 -4
- experimaestro/connectors/__init__.py +9 -1
- experimaestro/connectors/local.py +43 -3
- experimaestro/core/arguments.py +18 -18
- experimaestro/core/identifier.py +11 -11
- experimaestro/core/objects/config.py +96 -39
- experimaestro/core/objects/config_walk.py +3 -3
- experimaestro/core/{subparameters.py → partial.py} +16 -16
- experimaestro/core/partial_lock.py +394 -0
- experimaestro/core/types.py +12 -15
- experimaestro/dynamic.py +290 -0
- experimaestro/experiments/__init__.py +6 -2
- experimaestro/experiments/cli.py +217 -50
- experimaestro/experiments/configuration.py +24 -0
- experimaestro/generators.py +5 -5
- experimaestro/ipc.py +118 -1
- experimaestro/launcherfinder/__init__.py +2 -2
- experimaestro/launcherfinder/registry.py +6 -7
- experimaestro/launcherfinder/specs.py +2 -9
- experimaestro/launchers/slurm/__init__.py +2 -2
- experimaestro/launchers/slurm/base.py +62 -0
- experimaestro/locking.py +957 -1
- experimaestro/notifications.py +89 -201
- experimaestro/progress.py +63 -366
- experimaestro/rpyc.py +0 -2
- experimaestro/run.py +29 -2
- experimaestro/scheduler/__init__.py +8 -1
- experimaestro/scheduler/base.py +629 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +732 -167
- experimaestro/scheduler/interfaces.py +316 -101
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +171 -117
- experimaestro/scheduler/remote/protocol.py +8 -193
- experimaestro/scheduler/remote/server.py +95 -71
- experimaestro/scheduler/services.py +53 -28
- experimaestro/scheduler/state_provider.py +663 -2430
- experimaestro/scheduler/state_status.py +1247 -0
- experimaestro/scheduler/transient.py +31 -0
- experimaestro/scheduler/workspace.py +1 -1
- experimaestro/scheduler/workspace_state_provider.py +1273 -0
- experimaestro/scriptbuilder.py +4 -4
- experimaestro/settings.py +36 -0
- experimaestro/tests/conftest.py +33 -5
- experimaestro/tests/connectors/bin/executable.py +1 -1
- experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
- experimaestro/tests/launchers/bin/test.py +1 -0
- experimaestro/tests/launchers/test_slurm.py +9 -9
- experimaestro/tests/partial_reschedule.py +46 -0
- experimaestro/tests/restart.py +3 -3
- experimaestro/tests/restart_main.py +1 -0
- experimaestro/tests/scripts/notifyandwait.py +1 -0
- experimaestro/tests/task_partial.py +38 -0
- experimaestro/tests/task_tokens.py +2 -2
- experimaestro/tests/tasks/test_dynamic.py +6 -6
- experimaestro/tests/test_dependencies.py +3 -3
- experimaestro/tests/test_deprecated.py +15 -15
- experimaestro/tests/test_dynamic_locking.py +317 -0
- experimaestro/tests/test_environment.py +24 -14
- experimaestro/tests/test_experiment.py +171 -36
- experimaestro/tests/test_identifier.py +25 -25
- experimaestro/tests/test_identifier_stability.py +3 -5
- experimaestro/tests/test_multitoken.py +2 -4
- experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
- experimaestro/tests/test_partial_paths.py +81 -138
- experimaestro/tests/test_pre_experiment.py +219 -0
- experimaestro/tests/test_progress.py +2 -8
- experimaestro/tests/test_remote_state.py +560 -99
- experimaestro/tests/test_stray_jobs.py +261 -0
- experimaestro/tests/test_tasks.py +1 -2
- experimaestro/tests/test_token_locking.py +52 -67
- experimaestro/tests/test_tokens.py +5 -6
- experimaestro/tests/test_transient.py +225 -0
- experimaestro/tests/test_workspace_state_provider.py +768 -0
- experimaestro/tests/token_reschedule.py +1 -3
- experimaestro/tests/utils.py +2 -7
- experimaestro/tokens.py +227 -372
- experimaestro/tools/diff.py +1 -0
- experimaestro/tools/documentation.py +4 -5
- experimaestro/tools/jobs.py +1 -2
- experimaestro/tui/app.py +438 -1966
- experimaestro/tui/app.tcss +162 -0
- experimaestro/tui/dialogs.py +172 -0
- experimaestro/tui/log_viewer.py +253 -3
- experimaestro/tui/messages.py +137 -0
- experimaestro/tui/utils.py +54 -0
- experimaestro/tui/widgets/__init__.py +23 -0
- experimaestro/tui/widgets/experiments.py +468 -0
- experimaestro/tui/widgets/global_services.py +238 -0
- experimaestro/tui/widgets/jobs.py +972 -0
- experimaestro/tui/widgets/log.py +156 -0
- experimaestro/tui/widgets/orphans.py +363 -0
- experimaestro/tui/widgets/runs.py +185 -0
- experimaestro/tui/widgets/services.py +314 -0
- experimaestro/tui/widgets/stray_jobs.py +528 -0
- experimaestro/utils/__init__.py +1 -1
- experimaestro/utils/environment.py +105 -22
- experimaestro/utils/fswatcher.py +124 -0
- experimaestro/utils/jobs.py +1 -2
- experimaestro/utils/jupyter.py +1 -2
- experimaestro/utils/logging.py +72 -0
- experimaestro/version.py +2 -2
- experimaestro/webui/__init__.py +9 -0
- experimaestro/webui/app.py +117 -0
- experimaestro/{server → webui}/data/index.css +66 -11
- experimaestro/webui/data/index.css.map +1 -0
- experimaestro/{server → webui}/data/index.js +82763 -87217
- experimaestro/webui/data/index.js.map +1 -0
- experimaestro/webui/routes/__init__.py +5 -0
- experimaestro/webui/routes/auth.py +53 -0
- experimaestro/webui/routes/proxy.py +117 -0
- experimaestro/webui/server.py +200 -0
- experimaestro/webui/state_bridge.py +152 -0
- experimaestro/webui/websocket.py +413 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
- experimaestro-2.0.0b17.dist-info/RECORD +219 -0
- experimaestro/cli/progress.py +0 -269
- experimaestro/scheduler/state.py +0 -75
- experimaestro/scheduler/state_db.py +0 -437
- experimaestro/scheduler/state_sync.py +0 -891
- experimaestro/server/__init__.py +0 -467
- experimaestro/server/data/index.css.map +0 -1
- experimaestro/server/data/index.js.map +0 -1
- experimaestro/tests/test_cli_jobs.py +0 -615
- experimaestro/tests/test_file_progress.py +0 -425
- experimaestro/tests/test_file_progress_integration.py +0 -477
- experimaestro/tests/test_state_db.py +0 -434
- experimaestro-2.0.0b8.dist-info/RECORD +0 -187
- /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
- /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
- /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
- /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
- /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
- /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
- /experimaestro/{server → webui}/data/favicon.ico +0 -0
- /experimaestro/{server → webui}/data/index.html +0 -0
- /experimaestro/{server → webui}/data/login.html +0 -0
- /experimaestro/{server → webui}/data/manifest.json +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Authentication routes for WebUI"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from fastapi import APIRouter, Request, Response, Query
|
|
6
|
+
from fastapi.responses import RedirectResponse
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("xpm.webui.auth")
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/auth")
|
|
14
|
+
async def auth(
|
|
15
|
+
request: Request,
|
|
16
|
+
xpm_token: Optional[str] = Query(None, alias="xpm-token"),
|
|
17
|
+
):
|
|
18
|
+
"""Authenticate with token and set cookie
|
|
19
|
+
|
|
20
|
+
If valid token provided, redirects to index.html with cookie set.
|
|
21
|
+
The token is passed as ?xpm-token=... in the URL.
|
|
22
|
+
"""
|
|
23
|
+
server = request.app.state.server
|
|
24
|
+
|
|
25
|
+
if xpm_token and server.token == xpm_token:
|
|
26
|
+
response = RedirectResponse(url="/index.html", status_code=302)
|
|
27
|
+
response.set_cookie(
|
|
28
|
+
key="experimaestro_token",
|
|
29
|
+
value=xpm_token,
|
|
30
|
+
httponly=True,
|
|
31
|
+
samesite="lax",
|
|
32
|
+
)
|
|
33
|
+
return response
|
|
34
|
+
|
|
35
|
+
return RedirectResponse(url="/login.html", status_code=302)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/stop")
|
|
39
|
+
async def stop(
|
|
40
|
+
request: Request,
|
|
41
|
+
xpm_token: Optional[str] = Query(None, alias="xpm-token"),
|
|
42
|
+
):
|
|
43
|
+
"""Stop the server (requires authentication)"""
|
|
44
|
+
server = request.app.state.server
|
|
45
|
+
|
|
46
|
+
# Check token from query param or cookie
|
|
47
|
+
token = xpm_token or request.cookies.get("experimaestro_token")
|
|
48
|
+
|
|
49
|
+
if server.token == token:
|
|
50
|
+
server.request_quit()
|
|
51
|
+
return Response(status_code=202)
|
|
52
|
+
|
|
53
|
+
return Response(status_code=401)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Service proxy routes for WebUI"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request, Response
|
|
6
|
+
from fastapi.responses import RedirectResponse
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("xpm.webui.proxy")
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.api_route(
|
|
14
|
+
"/services/{service_id}/{path:path}",
|
|
15
|
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
16
|
+
)
|
|
17
|
+
async def proxy_service(request: Request, service_id: str, path: str = ""):
|
|
18
|
+
"""Proxy requests to a service
|
|
19
|
+
|
|
20
|
+
Routes /services/{service_id}/{path} to the service's URL.
|
|
21
|
+
"""
|
|
22
|
+
server = request.app.state.server
|
|
23
|
+
|
|
24
|
+
# Get service from scheduler (only works in active mode)
|
|
25
|
+
from experimaestro.scheduler.base import Scheduler
|
|
26
|
+
|
|
27
|
+
if not isinstance(server.state_provider, Scheduler):
|
|
28
|
+
return Response(
|
|
29
|
+
content="Service proxy only available in active mode",
|
|
30
|
+
status_code=503,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
scheduler: Scheduler = server.state_provider
|
|
34
|
+
|
|
35
|
+
# Find service in experiments
|
|
36
|
+
service = None
|
|
37
|
+
for xp in scheduler.experiments.values():
|
|
38
|
+
service = xp.services.get(service_id)
|
|
39
|
+
if service:
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if service is None:
|
|
43
|
+
return Response(
|
|
44
|
+
content=f"Service {service_id} not found",
|
|
45
|
+
status_code=404,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Get service URL
|
|
49
|
+
base_url = service.get_url()
|
|
50
|
+
if not base_url:
|
|
51
|
+
return Response(
|
|
52
|
+
content=f"Service {service_id} has no URL",
|
|
53
|
+
status_code=503,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Proxy the request using httpx
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
|
+
# Build target URL
|
|
60
|
+
target_url = f"{base_url}/{path}"
|
|
61
|
+
if request.query_params:
|
|
62
|
+
target_url = f"{target_url}?{request.query_params}"
|
|
63
|
+
|
|
64
|
+
# Forward headers (filter sensitive ones)
|
|
65
|
+
headers = {}
|
|
66
|
+
for key, value in request.headers.items():
|
|
67
|
+
key_lower = key.lower()
|
|
68
|
+
if key_lower not in ("host", "content-length", "transfer-encoding"):
|
|
69
|
+
headers[key] = value
|
|
70
|
+
|
|
71
|
+
# Get request body for POST/PUT/PATCH
|
|
72
|
+
body = None
|
|
73
|
+
if request.method in ("POST", "PUT", "PATCH"):
|
|
74
|
+
body = await request.body()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
async with httpx.AsyncClient() as client:
|
|
78
|
+
response = await client.request(
|
|
79
|
+
method=request.method,
|
|
80
|
+
url=target_url,
|
|
81
|
+
headers=headers,
|
|
82
|
+
content=body,
|
|
83
|
+
follow_redirects=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Build response
|
|
87
|
+
response_headers = {}
|
|
88
|
+
for key, value in response.headers.items():
|
|
89
|
+
key_lower = key.lower()
|
|
90
|
+
if key_lower not in (
|
|
91
|
+
"content-encoding",
|
|
92
|
+
"transfer-encoding",
|
|
93
|
+
"content-length",
|
|
94
|
+
):
|
|
95
|
+
response_headers[key] = value
|
|
96
|
+
|
|
97
|
+
return Response(
|
|
98
|
+
content=response.content,
|
|
99
|
+
status_code=response.status_code,
|
|
100
|
+
headers=response_headers,
|
|
101
|
+
media_type=response.headers.get("content-type"),
|
|
102
|
+
)
|
|
103
|
+
except httpx.RequestError as e:
|
|
104
|
+
logger.error("Proxy error for service %s: %s", service_id, e)
|
|
105
|
+
return Response(
|
|
106
|
+
content=f"Proxy error: {e}",
|
|
107
|
+
status_code=502,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/services/{service_id}")
|
|
112
|
+
async def redirect_service(service_id: str):
|
|
113
|
+
"""Redirect to service with trailing slash"""
|
|
114
|
+
return RedirectResponse(
|
|
115
|
+
url=f"/services/{service_id}/",
|
|
116
|
+
status_code=308, # Permanent redirect, preserves method
|
|
117
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""WebUI Server implementation
|
|
2
|
+
|
|
3
|
+
Aligned with TUI's ExperimaestroUI pattern, using StateProvider abstraction.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import platform
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import ClassVar, Optional
|
|
12
|
+
|
|
13
|
+
from experimaestro.scheduler.state_provider import StateProvider
|
|
14
|
+
from experimaestro.settings import ServerSettings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("xpm.webui")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WebUIServer:
|
|
20
|
+
"""WebUI server for monitoring experiments
|
|
21
|
+
|
|
22
|
+
Aligned with TUI's ExperimaestroUI pattern:
|
|
23
|
+
- Uses StateProvider abstraction for data access
|
|
24
|
+
- Supports both embedded mode (live scheduler) and offline mode (database)
|
|
25
|
+
- Can wait for explicit quit from interface (like TUI)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_instance: ClassVar[Optional["WebUIServer"]] = None
|
|
29
|
+
_lock: ClassVar[threading.Lock] = threading.Lock()
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def instance(
|
|
33
|
+
settings: ServerSettings = None,
|
|
34
|
+
state_provider: StateProvider = None,
|
|
35
|
+
wait_for_quit: bool = False,
|
|
36
|
+
) -> "WebUIServer":
|
|
37
|
+
"""Get or create the global server instance
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
settings: Server settings (optional, uses defaults if not provided)
|
|
41
|
+
state_provider: StateProvider instance (required)
|
|
42
|
+
wait_for_quit: If True, server waits for explicit quit from UI
|
|
43
|
+
"""
|
|
44
|
+
if WebUIServer._instance is None:
|
|
45
|
+
with WebUIServer._lock:
|
|
46
|
+
if WebUIServer._instance is None:
|
|
47
|
+
if settings is None:
|
|
48
|
+
from experimaestro.settings import get_settings
|
|
49
|
+
|
|
50
|
+
settings = get_settings().server
|
|
51
|
+
|
|
52
|
+
if state_provider is None:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"state_provider parameter is required. "
|
|
55
|
+
"Pass the Scheduler instance or WorkspaceStateProvider."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
WebUIServer._instance = WebUIServer(
|
|
59
|
+
settings, state_provider, wait_for_quit
|
|
60
|
+
)
|
|
61
|
+
return WebUIServer._instance
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def clear_instance():
|
|
65
|
+
"""Clear the singleton instance (for testing)"""
|
|
66
|
+
with WebUIServer._lock:
|
|
67
|
+
WebUIServer._instance = None
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
settings: ServerSettings,
|
|
72
|
+
state_provider: StateProvider,
|
|
73
|
+
wait_for_quit: bool = False,
|
|
74
|
+
):
|
|
75
|
+
"""Initialize the WebUI server
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
settings: Server settings
|
|
79
|
+
state_provider: StateProvider for accessing experiment/job data
|
|
80
|
+
wait_for_quit: If True, wait for explicit quit from web interface
|
|
81
|
+
"""
|
|
82
|
+
# Determine host binding
|
|
83
|
+
if settings.autohost == "fqdn":
|
|
84
|
+
settings.host = socket.getfqdn()
|
|
85
|
+
logger.info("Auto host name (fqdn): %s", settings.host)
|
|
86
|
+
elif settings.autohost == "name":
|
|
87
|
+
settings.host = platform.node()
|
|
88
|
+
logger.info("Auto host name (name): %s", settings.host)
|
|
89
|
+
|
|
90
|
+
if settings.host is None or settings.host == "127.0.0.1":
|
|
91
|
+
self.binding_host = "127.0.0.1"
|
|
92
|
+
else:
|
|
93
|
+
self.binding_host = "0.0.0.0"
|
|
94
|
+
|
|
95
|
+
self.host = settings.host or "127.0.0.1"
|
|
96
|
+
self.port = settings.port
|
|
97
|
+
self.token = settings.token or uuid.uuid4().hex
|
|
98
|
+
self.state_provider = state_provider
|
|
99
|
+
self.wait_for_quit = wait_for_quit
|
|
100
|
+
|
|
101
|
+
# Check if we have an active experiment (scheduler as state provider)
|
|
102
|
+
from experimaestro.scheduler.base import Scheduler
|
|
103
|
+
|
|
104
|
+
self._has_active_experiment = isinstance(state_provider, Scheduler)
|
|
105
|
+
|
|
106
|
+
# Threading state
|
|
107
|
+
self._thread: Optional[threading.Thread] = None
|
|
108
|
+
self._running = False
|
|
109
|
+
self._cv_running = threading.Condition()
|
|
110
|
+
self._quit_event = threading.Event()
|
|
111
|
+
|
|
112
|
+
# Uvicorn server reference
|
|
113
|
+
self._uvicorn_server = None
|
|
114
|
+
|
|
115
|
+
def start(self):
|
|
116
|
+
"""Start the web server in a daemon thread"""
|
|
117
|
+
logger.info("Starting the web server")
|
|
118
|
+
|
|
119
|
+
self._thread = threading.Thread(
|
|
120
|
+
target=self._run_server,
|
|
121
|
+
daemon=True,
|
|
122
|
+
name="webui-server",
|
|
123
|
+
)
|
|
124
|
+
self._thread.start()
|
|
125
|
+
|
|
126
|
+
# Wait until server is ready
|
|
127
|
+
with self._cv_running:
|
|
128
|
+
self._cv_running.wait_for(lambda: self._running)
|
|
129
|
+
|
|
130
|
+
logger.info(
|
|
131
|
+
"Web server started on http://%s:%d/auth?xpm-token=%s",
|
|
132
|
+
self.host,
|
|
133
|
+
self.port,
|
|
134
|
+
self.token,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _run_server(self):
|
|
138
|
+
"""Run the uvicorn server (called in thread)"""
|
|
139
|
+
import uvicorn
|
|
140
|
+
|
|
141
|
+
from experimaestro.webui.app import create_app
|
|
142
|
+
|
|
143
|
+
# Find available port if needed
|
|
144
|
+
if self.port is None or self.port == 0:
|
|
145
|
+
logger.info("Searching for an available port")
|
|
146
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
147
|
+
sock.bind(("", 0))
|
|
148
|
+
self.port = sock.getsockname()[1]
|
|
149
|
+
sock.close()
|
|
150
|
+
|
|
151
|
+
# Create FastAPI app
|
|
152
|
+
app = create_app(self)
|
|
153
|
+
|
|
154
|
+
# Configure uvicorn
|
|
155
|
+
config = uvicorn.Config(
|
|
156
|
+
app,
|
|
157
|
+
host=self.binding_host,
|
|
158
|
+
port=self.port,
|
|
159
|
+
log_level="warning",
|
|
160
|
+
access_log=False,
|
|
161
|
+
)
|
|
162
|
+
self._uvicorn_server = uvicorn.Server(config)
|
|
163
|
+
|
|
164
|
+
# Signal that we're running
|
|
165
|
+
with self._cv_running:
|
|
166
|
+
self._running = True
|
|
167
|
+
self._cv_running.notify_all()
|
|
168
|
+
|
|
169
|
+
# Run server (blocks until shutdown)
|
|
170
|
+
self._uvicorn_server.run()
|
|
171
|
+
logger.info("Web server stopped")
|
|
172
|
+
|
|
173
|
+
def stop(self):
|
|
174
|
+
"""Stop the server gracefully"""
|
|
175
|
+
if self._uvicorn_server:
|
|
176
|
+
self._uvicorn_server.should_exit = True
|
|
177
|
+
|
|
178
|
+
def request_quit(self):
|
|
179
|
+
"""Signal quit request from web interface"""
|
|
180
|
+
logger.info("Quit requested from web interface")
|
|
181
|
+
self._quit_event.set()
|
|
182
|
+
self.stop()
|
|
183
|
+
|
|
184
|
+
def wait(self):
|
|
185
|
+
"""Wait for explicit quit from web interface
|
|
186
|
+
|
|
187
|
+
Call this after experiment completion when wait_for_quit=True.
|
|
188
|
+
Blocks until user clicks Quit button in the web UI.
|
|
189
|
+
"""
|
|
190
|
+
if not self.wait_for_quit:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
logger.info("Waiting for quit from web interface...")
|
|
194
|
+
self._quit_event.wait()
|
|
195
|
+
logger.info("Quit signal received")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def is_running(self) -> bool:
|
|
199
|
+
"""Check if server is running"""
|
|
200
|
+
return self._running
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""State Bridge - connects StateProvider events to WebSocket broadcasts
|
|
2
|
+
|
|
3
|
+
Mirrors the TUI's STATE_EVENT_HANDLERS pattern for consistent event handling.
|
|
4
|
+
Uses db_state_dict() serialization consistent with SSHStateProviderServer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from experimaestro.scheduler.state_provider import StateProvider
|
|
12
|
+
from experimaestro.scheduler.state_status import (
|
|
13
|
+
EventBase,
|
|
14
|
+
ExperimentUpdatedEvent,
|
|
15
|
+
JobStateChangedEvent,
|
|
16
|
+
JobSubmittedEvent,
|
|
17
|
+
ServiceAddedEvent,
|
|
18
|
+
RunUpdatedEvent,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from experimaestro.webui.websocket import WebSocketHandler
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("xpm.webui.state_bridge")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StateBridge:
|
|
28
|
+
"""Bridges StateProvider events to WebSocket broadcasts
|
|
29
|
+
|
|
30
|
+
Similar to TUI's STATE_EVENT_HANDLERS pattern, converts events
|
|
31
|
+
to WebSocket messages and broadcasts to all connected clients.
|
|
32
|
+
|
|
33
|
+
Uses db_state_dict() for consistent serialization with SSHStateProviderServer.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
state_provider: StateProvider,
|
|
39
|
+
ws_handler: "WebSocketHandler",
|
|
40
|
+
):
|
|
41
|
+
"""Initialize state bridge
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
state_provider: StateProvider to listen to
|
|
45
|
+
ws_handler: WebSocket handler for broadcasting
|
|
46
|
+
"""
|
|
47
|
+
self.state_provider = state_provider
|
|
48
|
+
self.ws_handler = ws_handler
|
|
49
|
+
self._loop: asyncio.AbstractEventLoop = None
|
|
50
|
+
|
|
51
|
+
# Register as listener
|
|
52
|
+
state_provider.add_listener(self._on_state_event)
|
|
53
|
+
|
|
54
|
+
def _on_state_event(self, event: EventBase):
|
|
55
|
+
"""Handle state event from provider
|
|
56
|
+
|
|
57
|
+
Called from provider's thread, schedules async broadcast.
|
|
58
|
+
"""
|
|
59
|
+
# Get or create event loop for async operations
|
|
60
|
+
try:
|
|
61
|
+
# Try to get running loop (if called from async context)
|
|
62
|
+
loop = asyncio.get_running_loop()
|
|
63
|
+
loop.create_task(self._handle_event_async(event))
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
# No running loop - create new one or use thread-safe call
|
|
66
|
+
asyncio.run(self._handle_event_async(event))
|
|
67
|
+
|
|
68
|
+
async def _handle_event_async(self, event: EventBase):
|
|
69
|
+
"""Handle state event asynchronously"""
|
|
70
|
+
handler = self._get_handler(event)
|
|
71
|
+
if handler:
|
|
72
|
+
await handler(event)
|
|
73
|
+
|
|
74
|
+
def _get_handler(self, event: EventBase):
|
|
75
|
+
"""Get handler for event type"""
|
|
76
|
+
handlers = {
|
|
77
|
+
ExperimentUpdatedEvent: self._handle_experiment_updated,
|
|
78
|
+
JobStateChangedEvent: self._handle_job_state_changed,
|
|
79
|
+
JobSubmittedEvent: self._handle_job_submitted,
|
|
80
|
+
ServiceAddedEvent: self._handle_service_added,
|
|
81
|
+
RunUpdatedEvent: self._handle_run_updated,
|
|
82
|
+
}
|
|
83
|
+
return handlers.get(type(event))
|
|
84
|
+
|
|
85
|
+
async def _handle_experiment_updated(self, event: ExperimentUpdatedEvent):
|
|
86
|
+
"""Handle experiment update - broadcast experiment.add"""
|
|
87
|
+
from experimaestro.webui.websocket import serialize_experiment
|
|
88
|
+
|
|
89
|
+
# Fetch experiment from state provider
|
|
90
|
+
experiment = self.state_provider.get_experiment(event.experiment_id)
|
|
91
|
+
if experiment:
|
|
92
|
+
payload = serialize_experiment(experiment)
|
|
93
|
+
await self.ws_handler.broadcast("experiment.add", payload)
|
|
94
|
+
|
|
95
|
+
async def _handle_job_state_changed(self, event: JobStateChangedEvent):
|
|
96
|
+
"""Handle job state change - broadcast job.update"""
|
|
97
|
+
from experimaestro.webui.websocket import job_db_to_frontend
|
|
98
|
+
|
|
99
|
+
# Fetch job from state provider
|
|
100
|
+
# Note: get_all_jobs returns jobs across all experiments
|
|
101
|
+
jobs = self.state_provider.get_all_jobs()
|
|
102
|
+
job = next((j for j in jobs if j.identifier == event.job_id), None)
|
|
103
|
+
if job:
|
|
104
|
+
payload = job_db_to_frontend(job.db_state_dict())
|
|
105
|
+
await self.ws_handler.broadcast("job.update", payload)
|
|
106
|
+
|
|
107
|
+
async def _handle_job_submitted(self, event: JobSubmittedEvent):
|
|
108
|
+
"""Handle job added to experiment - broadcast job.add"""
|
|
109
|
+
from experimaestro.webui.websocket import job_db_to_frontend
|
|
110
|
+
|
|
111
|
+
# Fetch the full job data from state provider
|
|
112
|
+
job = self.state_provider.get_job(event.job_id, event.experiment_id)
|
|
113
|
+
if job:
|
|
114
|
+
# Get base dict from db_state_dict
|
|
115
|
+
db_dict = job.db_state_dict()
|
|
116
|
+
|
|
117
|
+
# Add tags and dependencies from event
|
|
118
|
+
db_dict["tags"] = (
|
|
119
|
+
[(tag.key, tag.value) for tag in event.tags] if event.tags else []
|
|
120
|
+
)
|
|
121
|
+
db_dict["depends_on"] = event.depends_on or []
|
|
122
|
+
|
|
123
|
+
payload = job_db_to_frontend(db_dict)
|
|
124
|
+
await self.ws_handler.broadcast("job.add", payload)
|
|
125
|
+
|
|
126
|
+
async def _handle_service_added(self, event: ServiceAddedEvent):
|
|
127
|
+
"""Handle service added - broadcast service.add"""
|
|
128
|
+
from experimaestro.webui.websocket import service_db_to_frontend
|
|
129
|
+
|
|
130
|
+
# Fetch service from state provider
|
|
131
|
+
services = self.state_provider.get_services(event.experiment_id, event.run_id)
|
|
132
|
+
service = next((s for s in services if s.id == event.service_id), None)
|
|
133
|
+
if service:
|
|
134
|
+
payload = service_db_to_frontend(service.db_state_dict())
|
|
135
|
+
await self.ws_handler.broadcast("service.add", payload)
|
|
136
|
+
|
|
137
|
+
async def _handle_run_updated(self, event: RunUpdatedEvent):
|
|
138
|
+
"""Handle run update - triggers experiment refresh"""
|
|
139
|
+
from experimaestro.webui.websocket import serialize_experiment
|
|
140
|
+
|
|
141
|
+
# Run updates affect experiment stats, so broadcast experiment update
|
|
142
|
+
experiment = self.state_provider.get_experiment(event.experiment_id)
|
|
143
|
+
if experiment:
|
|
144
|
+
payload = serialize_experiment(experiment)
|
|
145
|
+
await self.ws_handler.broadcast("experiment.add", payload)
|
|
146
|
+
|
|
147
|
+
def close(self):
|
|
148
|
+
"""Clean up - remove listener"""
|
|
149
|
+
try:
|
|
150
|
+
self.state_provider.remove_listener(self._on_state_event)
|
|
151
|
+
except ValueError:
|
|
152
|
+
pass # Already removed
|