experimaestro 2.0.0b4__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 +393 -134
- 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 +223 -52
- 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 +650 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +764 -169
- experimaestro/scheduler/interfaces.py +338 -96
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/__init__.py +31 -0
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +928 -0
- experimaestro/scheduler/remote/protocol.py +282 -0
- experimaestro/scheduler/remote/server.py +447 -0
- experimaestro/scheduler/remote/sync.py +144 -0
- experimaestro/scheduler/services.py +186 -35
- experimaestro/scheduler/state_provider.py +811 -2157
- 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 +1132 -0
- 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 +459 -1895
- 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.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +8 -9
- 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 -388
- experimaestro/scheduler/state_sync.py +0 -834
- 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.0b4.dist-info/RECORD +0 -181
- /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.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Message classes for TUI inter-widget communication"""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
from textual.message import Message
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from experimaestro.scheduler.interfaces import JobState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExperimentSelected(Message):
|
|
11
|
+
"""Message sent when an experiment is selected"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, experiment_id: str, run_id: Optional[str] = None) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.experiment_id = experiment_id
|
|
16
|
+
self.run_id = run_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExperimentDeselected(Message):
|
|
20
|
+
"""Message sent when an experiment is deselected"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobSelected(Message):
|
|
26
|
+
"""Message sent when a job is selected"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, job_id: str, experiment_id: str) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.job_id = job_id
|
|
31
|
+
self.experiment_id = experiment_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JobDeselected(Message):
|
|
35
|
+
"""Message sent when returning from job detail view"""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ViewJobLogs(Message):
|
|
41
|
+
"""Message sent when user wants to view job logs"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, job_path: str, task_id: str, job_state: Optional["JobState"] = None
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.job_path = job_path
|
|
48
|
+
self.task_id = task_id
|
|
49
|
+
self.job_state = job_state
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ViewJobLogsRequest(Message):
|
|
53
|
+
"""Message sent when user requests to view logs from jobs table"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, job_id: str, experiment_id: str) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.job_id = job_id
|
|
58
|
+
self.experiment_id = experiment_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeleteJobRequest(Message):
|
|
62
|
+
"""Message sent when user requests to delete a job"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, job_id: str, experiment_id: str) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.job_id = job_id
|
|
67
|
+
self.experiment_id = experiment_id
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DeleteExperimentRequest(Message):
|
|
71
|
+
"""Message sent when user requests to delete an experiment"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, experiment_id: str) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.experiment_id = experiment_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class KillJobRequest(Message):
|
|
79
|
+
"""Message sent when user requests to kill a running job"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, job_id: str, experiment_id: str) -> None:
|
|
82
|
+
super().__init__()
|
|
83
|
+
self.job_id = job_id
|
|
84
|
+
self.experiment_id = experiment_id
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class KillExperimentRequest(Message):
|
|
88
|
+
"""Message sent when user requests to kill all running jobs in an experiment"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, experiment_id: str) -> None:
|
|
91
|
+
super().__init__()
|
|
92
|
+
self.experiment_id = experiment_id
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FilterChanged(Message):
|
|
96
|
+
"""Message sent when search filter changes"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, filter_fn) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self.filter_fn = filter_fn
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SearchApplied(Message):
|
|
104
|
+
"""Message sent when search filter is applied via Enter"""
|
|
105
|
+
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SizeCalculated(Message):
|
|
110
|
+
"""Message sent when a folder size has been calculated"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, job_id: str, size: str, size_bytes: int) -> None:
|
|
113
|
+
super().__init__()
|
|
114
|
+
self.job_id = job_id
|
|
115
|
+
self.size = size
|
|
116
|
+
self.size_bytes = size_bytes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ShowRunsRequest(Message):
|
|
120
|
+
"""Message sent when user wants to see experiment runs"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, experiment_id: str, current_run_id: Optional[str]) -> None:
|
|
123
|
+
super().__init__()
|
|
124
|
+
self.experiment_id = experiment_id
|
|
125
|
+
self.current_run_id = current_run_id
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class RunSelected(Message):
|
|
129
|
+
"""Message sent when a run is selected from the runs screen"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self, experiment_id: str, run_id: str, is_current: bool = True
|
|
133
|
+
) -> None:
|
|
134
|
+
super().__init__()
|
|
135
|
+
self.experiment_id = experiment_id
|
|
136
|
+
self.run_id = run_id
|
|
137
|
+
self.is_current = is_current
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Utility functions for the TUI"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_duration(seconds: float) -> str:
|
|
5
|
+
"""Format duration in seconds to human-readable string"""
|
|
6
|
+
if seconds < 0:
|
|
7
|
+
return "-"
|
|
8
|
+
seconds = int(seconds)
|
|
9
|
+
if seconds < 60:
|
|
10
|
+
return f"{seconds}s"
|
|
11
|
+
elif seconds < 3600:
|
|
12
|
+
return f"{seconds // 60}m {seconds % 60}s"
|
|
13
|
+
elif seconds < 86400:
|
|
14
|
+
return f"{seconds // 3600}h {(seconds % 3600) // 60}m"
|
|
15
|
+
else:
|
|
16
|
+
return f"{seconds // 86400}d {(seconds % 86400) // 3600}h"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_status_icon(status: str, failure_reason=None, transient=None):
|
|
20
|
+
"""Get status icon for a job state.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
status: Job state name (e.g., "done", "error", "running")
|
|
24
|
+
failure_reason: Optional JobFailureStatus enum for error states
|
|
25
|
+
transient: Optional TransientMode enum
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Status icon string
|
|
29
|
+
"""
|
|
30
|
+
if status == "done":
|
|
31
|
+
return "✅"
|
|
32
|
+
elif status == "error":
|
|
33
|
+
# Show different icons for different failure types
|
|
34
|
+
if failure_reason is not None:
|
|
35
|
+
from experimaestro.scheduler.interfaces import JobFailureStatus
|
|
36
|
+
|
|
37
|
+
if failure_reason == JobFailureStatus.DEPENDENCY:
|
|
38
|
+
return "🔗" # Dependency failed
|
|
39
|
+
elif failure_reason == JobFailureStatus.TIMEOUT:
|
|
40
|
+
return "⏱" # Timeout
|
|
41
|
+
elif failure_reason == JobFailureStatus.MEMORY:
|
|
42
|
+
return "💾" # Memory issue
|
|
43
|
+
# FAILED or unknown - use default error icon
|
|
44
|
+
return "❌"
|
|
45
|
+
elif status == "running":
|
|
46
|
+
return "▶"
|
|
47
|
+
elif status == "waiting":
|
|
48
|
+
return "⌛" # Waiting for dependencies
|
|
49
|
+
elif status == "unscheduled" and transient is not None and transient.is_transient:
|
|
50
|
+
# Transient job that was skipped (not needed)
|
|
51
|
+
return "💤" # Sleeping - dormant, not activated
|
|
52
|
+
else:
|
|
53
|
+
# phantom, unscheduled or unknown
|
|
54
|
+
return "👻"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""TUI Widgets"""
|
|
2
|
+
|
|
3
|
+
from experimaestro.tui.widgets.log import CaptureLog
|
|
4
|
+
from experimaestro.tui.widgets.experiments import ExperimentsList
|
|
5
|
+
from experimaestro.tui.widgets.services import ServicesList
|
|
6
|
+
from experimaestro.tui.widgets.jobs import JobsTable, JobDetailView, SearchBar
|
|
7
|
+
from experimaestro.tui.widgets.orphans import OrphanJobsScreen
|
|
8
|
+
from experimaestro.tui.widgets.runs import RunsList
|
|
9
|
+
from experimaestro.tui.widgets.global_services import GlobalServiceSyncs
|
|
10
|
+
from experimaestro.tui.widgets.stray_jobs import OrphanJobsTab
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CaptureLog",
|
|
14
|
+
"ExperimentsList",
|
|
15
|
+
"ServicesList",
|
|
16
|
+
"JobsTable",
|
|
17
|
+
"JobDetailView",
|
|
18
|
+
"SearchBar",
|
|
19
|
+
"OrphanJobsScreen",
|
|
20
|
+
"OrphanJobsTab",
|
|
21
|
+
"RunsList",
|
|
22
|
+
"GlobalServiceSyncs",
|
|
23
|
+
]
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Experiments list widget for the TUI"""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import time as time_module
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Container, Horizontal
|
|
8
|
+
from textual.widgets import DataTable, Label
|
|
9
|
+
from textual.widget import Widget
|
|
10
|
+
from textual.reactive import reactive
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
|
|
13
|
+
from experimaestro.scheduler.state_provider import StateProvider
|
|
14
|
+
from experimaestro.tui.utils import format_duration
|
|
15
|
+
from experimaestro.tui.messages import (
|
|
16
|
+
ExperimentSelected,
|
|
17
|
+
ExperimentDeselected,
|
|
18
|
+
DeleteExperimentRequest,
|
|
19
|
+
KillExperimentRequest,
|
|
20
|
+
ShowRunsRequest,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExperimentsList(Widget):
|
|
25
|
+
"""Widget displaying list of experiments"""
|
|
26
|
+
|
|
27
|
+
BINDINGS = [
|
|
28
|
+
Binding("d", "show_runs", "Runs"),
|
|
29
|
+
Binding("ctrl+d", "delete_experiment", "Delete", show=False),
|
|
30
|
+
Binding("k", "kill_experiment", "Kill", show=False),
|
|
31
|
+
Binding("S", "sort_by_status", "Sort ⚑", show=False),
|
|
32
|
+
Binding("D", "sort_by_date", "Sort Date", show=False),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
current_experiment: reactive[Optional[str]] = reactive(None)
|
|
36
|
+
collapsed: reactive[bool] = reactive(False)
|
|
37
|
+
|
|
38
|
+
# Track current sort state
|
|
39
|
+
_sort_column: Optional[str] = None
|
|
40
|
+
_sort_reverse: bool = False
|
|
41
|
+
|
|
42
|
+
# Status sort order (for sorting by status)
|
|
43
|
+
STATUS_ORDER = {
|
|
44
|
+
"failed": 0, # Failed experiments first (need attention)
|
|
45
|
+
"running": 1, # Running experiments
|
|
46
|
+
"done": 2, # Completed experiments
|
|
47
|
+
"empty": 3, # Empty experiments last
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Column key to display name mapping
|
|
51
|
+
COLUMN_LABELS = {
|
|
52
|
+
"id": "ID",
|
|
53
|
+
"run": "Run",
|
|
54
|
+
"runs": "#",
|
|
55
|
+
"host": "Host",
|
|
56
|
+
"jobs": "Jobs",
|
|
57
|
+
"status": "Status",
|
|
58
|
+
"started": "Started",
|
|
59
|
+
"duration": "Duration",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Columns that support sorting (column key -> sort column name)
|
|
63
|
+
SORTABLE_COLUMNS = {
|
|
64
|
+
"status": "status",
|
|
65
|
+
"started": "started",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def __init__(self, state_provider: StateProvider) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.state_provider = state_provider
|
|
71
|
+
self.experiments = []
|
|
72
|
+
|
|
73
|
+
def _get_selected_experiment_id(self) -> Optional[str]:
|
|
74
|
+
"""Get the experiment ID from the currently selected row"""
|
|
75
|
+
table = self.query_one("#experiments-table", DataTable)
|
|
76
|
+
if table.cursor_row is None:
|
|
77
|
+
return None
|
|
78
|
+
row_key = list(table.rows.keys())[table.cursor_row]
|
|
79
|
+
if row_key:
|
|
80
|
+
return str(row_key.value)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def action_delete_experiment(self) -> None:
|
|
84
|
+
"""Request to delete the selected experiment"""
|
|
85
|
+
exp_id = self._get_selected_experiment_id()
|
|
86
|
+
if exp_id:
|
|
87
|
+
self.post_message(DeleteExperimentRequest(exp_id))
|
|
88
|
+
|
|
89
|
+
def action_kill_experiment(self) -> None:
|
|
90
|
+
"""Request to kill all running jobs in the selected experiment"""
|
|
91
|
+
exp_id = self._get_selected_experiment_id()
|
|
92
|
+
if exp_id:
|
|
93
|
+
self.post_message(KillExperimentRequest(exp_id))
|
|
94
|
+
|
|
95
|
+
def action_show_runs(self) -> None:
|
|
96
|
+
"""Show runs for the selected experiment"""
|
|
97
|
+
exp_id = self._get_selected_experiment_id()
|
|
98
|
+
if exp_id:
|
|
99
|
+
# Get current run_id for this experiment
|
|
100
|
+
exp_info = next(
|
|
101
|
+
(exp for exp in self.experiments if exp.experiment_id == exp_id),
|
|
102
|
+
None,
|
|
103
|
+
)
|
|
104
|
+
current_run_id = (
|
|
105
|
+
getattr(exp_info, "current_run_id", None) if exp_info else None
|
|
106
|
+
)
|
|
107
|
+
self.post_message(ShowRunsRequest(exp_id, current_run_id))
|
|
108
|
+
|
|
109
|
+
def action_sort_by_status(self) -> None:
|
|
110
|
+
"""Sort experiments by status"""
|
|
111
|
+
if self._sort_column == "status":
|
|
112
|
+
self._sort_reverse = not self._sort_reverse
|
|
113
|
+
else:
|
|
114
|
+
self._sort_column = "status"
|
|
115
|
+
self._sort_reverse = False
|
|
116
|
+
self._update_column_headers()
|
|
117
|
+
self.refresh_experiments()
|
|
118
|
+
order = "desc" if self._sort_reverse else "asc"
|
|
119
|
+
self.notify(f"Sorted by status ({order})", severity="information")
|
|
120
|
+
|
|
121
|
+
def action_sort_by_date(self) -> None:
|
|
122
|
+
"""Sort experiments by start date"""
|
|
123
|
+
if self._sort_column == "started":
|
|
124
|
+
self._sort_reverse = not self._sort_reverse
|
|
125
|
+
else:
|
|
126
|
+
self._sort_column = "started"
|
|
127
|
+
self._sort_reverse = True # Default to newest first for date
|
|
128
|
+
self._update_column_headers()
|
|
129
|
+
self.refresh_experiments()
|
|
130
|
+
order = "newest first" if self._sort_reverse else "oldest first"
|
|
131
|
+
self.notify(f"Sorted by date ({order})", severity="information")
|
|
132
|
+
|
|
133
|
+
def _get_status_category(self, exp) -> str:
|
|
134
|
+
"""Get status category for an experiment (for sorting)"""
|
|
135
|
+
failed = exp.failed_jobs
|
|
136
|
+
total = exp.total_jobs
|
|
137
|
+
finished = exp.finished_jobs
|
|
138
|
+
|
|
139
|
+
if failed > 0:
|
|
140
|
+
return "failed"
|
|
141
|
+
elif finished == total and total > 0:
|
|
142
|
+
return "done"
|
|
143
|
+
elif finished < total:
|
|
144
|
+
return "running"
|
|
145
|
+
else:
|
|
146
|
+
return "empty"
|
|
147
|
+
|
|
148
|
+
def _get_status_sort_key(self, exp):
|
|
149
|
+
"""Get sort key for an experiment based on status"""
|
|
150
|
+
status_category = self._get_status_category(exp)
|
|
151
|
+
status_order = self.STATUS_ORDER.get(status_category, 99)
|
|
152
|
+
# Secondary sort by failed count (more failures first)
|
|
153
|
+
failed_count = exp.failed_jobs if status_category == "failed" else 0
|
|
154
|
+
return (status_order, -failed_count)
|
|
155
|
+
|
|
156
|
+
def _update_column_headers(self) -> None:
|
|
157
|
+
"""Update column headers with sort indicators"""
|
|
158
|
+
table = self.query_one("#experiments-table", DataTable)
|
|
159
|
+
for column in table.columns.values():
|
|
160
|
+
col_key = str(column.key.value) if column.key else None
|
|
161
|
+
if col_key and col_key in self.COLUMN_LABELS:
|
|
162
|
+
label = self.COLUMN_LABELS[col_key]
|
|
163
|
+
sort_col = self.SORTABLE_COLUMNS.get(col_key)
|
|
164
|
+
if sort_col and self._sort_column == sort_col:
|
|
165
|
+
# Add sort indicator
|
|
166
|
+
indicator = "▼" if self._sort_reverse else "▲"
|
|
167
|
+
new_label = f"{label} {indicator}"
|
|
168
|
+
else:
|
|
169
|
+
new_label = label
|
|
170
|
+
column.label = new_label
|
|
171
|
+
|
|
172
|
+
def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
|
|
173
|
+
"""Handle column header click for sorting"""
|
|
174
|
+
col_key = str(event.column_key.value) if event.column_key else None
|
|
175
|
+
if col_key and col_key in self.SORTABLE_COLUMNS:
|
|
176
|
+
sort_col = self.SORTABLE_COLUMNS[col_key]
|
|
177
|
+
if self._sort_column == sort_col:
|
|
178
|
+
self._sort_reverse = not self._sort_reverse
|
|
179
|
+
else:
|
|
180
|
+
self._sort_column = sort_col
|
|
181
|
+
# Default to reverse for date (newest first)
|
|
182
|
+
self._sort_reverse = sort_col == "started"
|
|
183
|
+
self._update_column_headers()
|
|
184
|
+
self.refresh_experiments()
|
|
185
|
+
|
|
186
|
+
def compose(self) -> ComposeResult:
|
|
187
|
+
# Collapsed header (hidden initially)
|
|
188
|
+
with Horizontal(id="collapsed-header", classes="hidden"):
|
|
189
|
+
yield Label("", id="collapsed-experiment-info")
|
|
190
|
+
|
|
191
|
+
# Full experiments table
|
|
192
|
+
with Container(id="experiments-table-container"):
|
|
193
|
+
yield Label("Experiments", classes="section-title")
|
|
194
|
+
yield DataTable(id="experiments-table", cursor_type="row")
|
|
195
|
+
|
|
196
|
+
def on_mount(self) -> None:
|
|
197
|
+
"""Initialize the experiments table"""
|
|
198
|
+
# Start expanded
|
|
199
|
+
self.add_class("expanded")
|
|
200
|
+
|
|
201
|
+
table = self.query_one("#experiments-table", DataTable)
|
|
202
|
+
table.add_column("ID", key="id")
|
|
203
|
+
table.add_column("Run", key="run")
|
|
204
|
+
table.add_column("#", key="runs", width=3)
|
|
205
|
+
table.add_column("Host", key="host")
|
|
206
|
+
table.add_column("Jobs", key="jobs")
|
|
207
|
+
table.add_column("Status", key="status")
|
|
208
|
+
table.add_column("Started", key="started")
|
|
209
|
+
table.add_column("Duration", key="duration")
|
|
210
|
+
self.refresh_experiments()
|
|
211
|
+
|
|
212
|
+
# If there's only one experiment, automatically select it
|
|
213
|
+
if len(self.experiments) == 1:
|
|
214
|
+
exp = self.experiments[0]
|
|
215
|
+
exp_id = exp.experiment_id
|
|
216
|
+
run_id = getattr(exp, "current_run_id", None)
|
|
217
|
+
self.current_experiment = exp_id
|
|
218
|
+
self.collapse_to_experiment(exp_id)
|
|
219
|
+
self.post_message(ExperimentSelected(exp_id, run_id))
|
|
220
|
+
|
|
221
|
+
def refresh_experiments(self) -> None: # noqa: C901
|
|
222
|
+
"""Refresh the experiments list from state provider"""
|
|
223
|
+
# Guard: ensure the table is mounted before querying
|
|
224
|
+
try:
|
|
225
|
+
table = self.query_one("#experiments-table", DataTable)
|
|
226
|
+
except Exception:
|
|
227
|
+
# Widget not yet fully composed, will be called again from on_mount
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Guard: ensure columns have been added (on_mount may not have run yet)
|
|
231
|
+
if len(table.columns) == 0:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
self.log.info(
|
|
235
|
+
f"State provider: {type(self.state_provider).__name__}, is_live={self.state_provider.is_live}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
self.experiments = self.state_provider.get_experiments()
|
|
240
|
+
self.log.debug(
|
|
241
|
+
f"Refreshing experiments: found {len(self.experiments)} experiments"
|
|
242
|
+
)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
self.log.error(f"ERROR refreshing experiments: {e}")
|
|
245
|
+
import traceback
|
|
246
|
+
|
|
247
|
+
self.log.error(traceback.format_exc())
|
|
248
|
+
self.experiments = []
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# Sort experiments based on selected column
|
|
252
|
+
experiments_sorted = list(self.experiments)
|
|
253
|
+
if self._sort_column == "status":
|
|
254
|
+
experiments_sorted.sort(
|
|
255
|
+
key=self._get_status_sort_key,
|
|
256
|
+
reverse=self._sort_reverse,
|
|
257
|
+
)
|
|
258
|
+
elif self._sort_column == "started":
|
|
259
|
+
# Sort by started time (experiments without start time go to end)
|
|
260
|
+
experiments_sorted.sort(
|
|
261
|
+
key=lambda e: e.started_at or 0,
|
|
262
|
+
reverse=self._sort_reverse,
|
|
263
|
+
)
|
|
264
|
+
# Default: no sorting, use order from state provider
|
|
265
|
+
|
|
266
|
+
# Get existing row keys
|
|
267
|
+
existing_keys = set(table.rows.keys())
|
|
268
|
+
current_exp_ids = set()
|
|
269
|
+
|
|
270
|
+
# Check if we need to rebuild (sort order may have changed)
|
|
271
|
+
current_order = [e.experiment_id for e in experiments_sorted]
|
|
272
|
+
existing_order = [str(k.value) for k in table.rows.keys()]
|
|
273
|
+
needs_rebuild = current_order != existing_order
|
|
274
|
+
|
|
275
|
+
# Build row data for all experiments
|
|
276
|
+
rows_data = {}
|
|
277
|
+
for exp in experiments_sorted:
|
|
278
|
+
exp_id = exp.experiment_id
|
|
279
|
+
current_exp_ids.add(exp_id)
|
|
280
|
+
total = exp.total_jobs
|
|
281
|
+
finished = exp.finished_jobs
|
|
282
|
+
failed = exp.failed_jobs
|
|
283
|
+
|
|
284
|
+
# Determine status
|
|
285
|
+
if failed > 0:
|
|
286
|
+
status = f"❌ {failed} failed"
|
|
287
|
+
elif finished == total and total > 0:
|
|
288
|
+
status = "✓ Done"
|
|
289
|
+
elif finished < total:
|
|
290
|
+
status = f"▶ {finished}/{total}"
|
|
291
|
+
else:
|
|
292
|
+
status = "Empty"
|
|
293
|
+
|
|
294
|
+
jobs_text = f"{finished}/{total}"
|
|
295
|
+
|
|
296
|
+
# Format started time
|
|
297
|
+
if exp.started_at:
|
|
298
|
+
started = datetime.fromtimestamp(exp.started_at).strftime(
|
|
299
|
+
"%Y-%m-%d %H:%M"
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
started = "-"
|
|
303
|
+
|
|
304
|
+
# Calculate duration
|
|
305
|
+
duration = "-"
|
|
306
|
+
if exp.started_at:
|
|
307
|
+
if exp.ended_at:
|
|
308
|
+
elapsed = exp.ended_at - exp.started_at
|
|
309
|
+
else:
|
|
310
|
+
# Still running - show elapsed time
|
|
311
|
+
elapsed = time_module.time() - exp.started_at
|
|
312
|
+
# Format duration
|
|
313
|
+
duration = format_duration(elapsed)
|
|
314
|
+
|
|
315
|
+
# Get hostname (may be None for older experiments)
|
|
316
|
+
hostname = getattr(exp, "hostname", None) or "-"
|
|
317
|
+
|
|
318
|
+
# Get run_id
|
|
319
|
+
run_id = getattr(exp, "current_run_id", None) or "-"
|
|
320
|
+
|
|
321
|
+
# Get runs count for this experiment (only for offline monitoring)
|
|
322
|
+
runs_count = "-"
|
|
323
|
+
if not self.state_provider.is_live:
|
|
324
|
+
try:
|
|
325
|
+
runs = self.state_provider.get_experiment_runs(exp_id)
|
|
326
|
+
runs_count = str(len(runs))
|
|
327
|
+
except Exception as e:
|
|
328
|
+
self.log.error(f"Error getting runs for {exp_id}: {e}")
|
|
329
|
+
import traceback
|
|
330
|
+
|
|
331
|
+
self.log.error(traceback.format_exc())
|
|
332
|
+
|
|
333
|
+
rows_data[exp_id] = (
|
|
334
|
+
exp_id,
|
|
335
|
+
run_id,
|
|
336
|
+
runs_count,
|
|
337
|
+
hostname,
|
|
338
|
+
jobs_text,
|
|
339
|
+
status,
|
|
340
|
+
started,
|
|
341
|
+
duration,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if needs_rebuild:
|
|
345
|
+
# Full rebuild needed - save selection, clear, rebuild
|
|
346
|
+
selected_key = None
|
|
347
|
+
if table.cursor_row is not None and table.row_count > 0:
|
|
348
|
+
try:
|
|
349
|
+
row_keys = list(table.rows.keys())
|
|
350
|
+
if table.cursor_row < len(row_keys):
|
|
351
|
+
selected_key = str(row_keys[table.cursor_row].value)
|
|
352
|
+
except (IndexError, KeyError):
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
table.clear()
|
|
356
|
+
new_cursor_row = None
|
|
357
|
+
for idx, exp in enumerate(experiments_sorted):
|
|
358
|
+
exp_id = exp.experiment_id
|
|
359
|
+
table.add_row(*rows_data[exp_id], key=exp_id)
|
|
360
|
+
if selected_key == exp_id:
|
|
361
|
+
new_cursor_row = idx
|
|
362
|
+
|
|
363
|
+
if new_cursor_row is not None and table.row_count > 0:
|
|
364
|
+
table.move_cursor(row=new_cursor_row)
|
|
365
|
+
else:
|
|
366
|
+
# Update cells in place (no reordering needed)
|
|
367
|
+
for exp_id, row_data in rows_data.items():
|
|
368
|
+
if exp_id in existing_keys:
|
|
369
|
+
(
|
|
370
|
+
_,
|
|
371
|
+
run_id,
|
|
372
|
+
runs_count,
|
|
373
|
+
hostname,
|
|
374
|
+
jobs_text,
|
|
375
|
+
status,
|
|
376
|
+
started,
|
|
377
|
+
duration,
|
|
378
|
+
) = row_data
|
|
379
|
+
table.update_cell(exp_id, "id", exp_id, update_width=True)
|
|
380
|
+
table.update_cell(exp_id, "run", run_id, update_width=True)
|
|
381
|
+
table.update_cell(exp_id, "runs", runs_count, update_width=True)
|
|
382
|
+
table.update_cell(exp_id, "host", hostname, update_width=True)
|
|
383
|
+
table.update_cell(exp_id, "jobs", jobs_text, update_width=True)
|
|
384
|
+
table.update_cell(exp_id, "status", status, update_width=True)
|
|
385
|
+
table.update_cell(exp_id, "started", started, update_width=True)
|
|
386
|
+
table.update_cell(exp_id, "duration", duration, update_width=True)
|
|
387
|
+
else:
|
|
388
|
+
table.add_row(*row_data, key=exp_id)
|
|
389
|
+
|
|
390
|
+
# Remove rows for experiments that no longer exist
|
|
391
|
+
for old_exp_id in existing_keys - current_exp_ids:
|
|
392
|
+
table.remove_row(old_exp_id)
|
|
393
|
+
|
|
394
|
+
# Update collapsed header if viewing an experiment
|
|
395
|
+
if self.collapsed and self.current_experiment:
|
|
396
|
+
self._update_collapsed_header(self.current_experiment)
|
|
397
|
+
|
|
398
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
399
|
+
"""Handle experiment selection"""
|
|
400
|
+
if event.row_key:
|
|
401
|
+
exp_id = str(event.row_key.value)
|
|
402
|
+
self.current_experiment = exp_id
|
|
403
|
+
self.collapse_to_experiment(self.current_experiment)
|
|
404
|
+
# Find run_id from experiments list
|
|
405
|
+
exp_info = next(
|
|
406
|
+
(exp for exp in self.experiments if exp.experiment_id == exp_id),
|
|
407
|
+
None,
|
|
408
|
+
)
|
|
409
|
+
run_id = getattr(exp_info, "current_run_id", None) if exp_info else None
|
|
410
|
+
self.post_message(ExperimentSelected(exp_id, run_id))
|
|
411
|
+
|
|
412
|
+
def _update_collapsed_header(self, experiment_id: str) -> None:
|
|
413
|
+
"""Update the collapsed experiment header with current stats"""
|
|
414
|
+
exp_info = next(
|
|
415
|
+
(exp for exp in self.experiments if exp.experiment_id == experiment_id),
|
|
416
|
+
None,
|
|
417
|
+
)
|
|
418
|
+
if not exp_info:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
total = exp_info.total_jobs
|
|
422
|
+
finished = exp_info.finished_jobs
|
|
423
|
+
failed = exp_info.failed_jobs
|
|
424
|
+
run_id = getattr(exp_info, "current_run_id", None)
|
|
425
|
+
|
|
426
|
+
if failed > 0:
|
|
427
|
+
status = f"❌ {failed} failed"
|
|
428
|
+
elif finished == total and total > 0:
|
|
429
|
+
status = "✓ Done"
|
|
430
|
+
elif finished < total:
|
|
431
|
+
status = f"▶ {finished}/{total}"
|
|
432
|
+
else:
|
|
433
|
+
status = "Empty"
|
|
434
|
+
|
|
435
|
+
collapsed_label = self.query_one("#collapsed-experiment-info", Label)
|
|
436
|
+
run_text = f" [{run_id}]" if run_id else ""
|
|
437
|
+
collapsed_label.update(
|
|
438
|
+
f"📊 {experiment_id}{run_text} - {status} (click to go back)"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def collapse_to_experiment(self, experiment_id: str) -> None:
|
|
442
|
+
"""Collapse the experiments list to show only the selected experiment"""
|
|
443
|
+
self._update_collapsed_header(experiment_id)
|
|
444
|
+
|
|
445
|
+
# Hide table, show collapsed header
|
|
446
|
+
self.query_one("#experiments-table-container").add_class("hidden")
|
|
447
|
+
self.query_one("#collapsed-header").remove_class("hidden")
|
|
448
|
+
self.collapsed = True
|
|
449
|
+
self.remove_class("expanded")
|
|
450
|
+
|
|
451
|
+
def expand_experiments(self) -> None:
|
|
452
|
+
"""Expand back to full experiments list"""
|
|
453
|
+
# Show table, hide collapsed header
|
|
454
|
+
self.query_one("#collapsed-header").add_class("hidden")
|
|
455
|
+
self.query_one("#experiments-table-container").remove_class("hidden")
|
|
456
|
+
self.collapsed = False
|
|
457
|
+
self.current_experiment = None
|
|
458
|
+
self.add_class("expanded")
|
|
459
|
+
|
|
460
|
+
# Focus the experiments table
|
|
461
|
+
table = self.query_one("#experiments-table", DataTable)
|
|
462
|
+
table.focus()
|
|
463
|
+
|
|
464
|
+
def on_click(self) -> None:
|
|
465
|
+
"""Handle clicks on the widget"""
|
|
466
|
+
if self.collapsed:
|
|
467
|
+
self.expand_experiments()
|
|
468
|
+
self.post_message(ExperimentDeselected())
|