Skip to content

Commit 2cf613d

Browse files
authored
Merge pull request #1304 from transformerlab/fix/improve-job-monitor-task-submit
Add Task Submit in CLI -- in job monitor and CLI commands
2 parents 4a3c924 + 335b3f4 commit 2cf613d

4 files changed

Lines changed: 444 additions & 16 deletions

File tree

cli/src/transformerlab_cli/commands/job_monitor/ExperimentSelectModal.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ class ExperimentSelectModal(ModalScreen):
3636
align: center middle;
3737
margin-bottom: 1;
3838
}
39+
Select {
40+
width: 100%;
41+
height: auto;
42+
min-height: 3;
43+
}
44+
Select > SelectCurrent {
45+
width: 1fr;
46+
height: auto;
47+
min-height: 1;
48+
padding: 0 1;
49+
}
50+
Select > SelectCurrent > Static {
51+
width: 1fr;
52+
}
3953
"""
4054

4155
BINDINGS = [("escape", "dismiss", "Close")]

cli/src/transformerlab_cli/commands/job_monitor/JobMonitorApp.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ def action_add_task(self) -> None:
8080
self.push_screen(TaskAddModal())
8181

8282
def action_list_tasks(self) -> None:
83-
self.push_screen(TaskListModal())
83+
current_experiment = get_current_experiment() or "alpha"
84+
self.push_screen(TaskListModal(experiment_id=current_experiment))
8485

8586
def action_refresh(self) -> None:
8687
"""Refresh the job list."""

cli/src/transformerlab_cli/commands/job_monitor/TaskListModal.py

Lines changed: 273 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,40 @@
66
Button,
77
OptionList,
88
Input,
9+
Switch,
10+
Select,
911
)
1012
from textual.widgets.option_list import Option
11-
from textual.containers import Vertical
13+
from textual.containers import Vertical, ScrollableContainer
1214
from textual.screen import ModalScreen
1315
from textual import work, on
1416

1517
from transformerlab_cli.util import api
18+
from transformerlab_cli.commands.task import fetch_providers, build_launch_payload, launch_task_on_provider
19+
20+
21+
def _infer_param_type(value) -> str:
22+
"""Infer parameter type from a simple shorthand value."""
23+
if isinstance(value, bool):
24+
return "bool"
25+
if isinstance(value, int):
26+
return "int"
27+
if isinstance(value, float):
28+
return "float"
29+
return "string"
30+
31+
32+
def _normalize_param(key: str, value) -> dict:
33+
"""Normalize a parameter definition to extended schema format."""
34+
if isinstance(value, dict) and "type" in value:
35+
schema = dict(value)
36+
schema.setdefault("title", key)
37+
return schema
38+
return {
39+
"type": _infer_param_type(value),
40+
"default": value,
41+
"title": key,
42+
}
1643

1744

1845
class TaskQueueModal(ModalScreen):
@@ -23,13 +50,17 @@ class TaskQueueModal(ModalScreen):
2350
align: center middle;
2451
}
2552
#queue-modal {
26-
width: 50%;
27-
min-width: 40;
28-
height: auto;
53+
width: 60%;
54+
min-width: 50;
55+
max-height: 80%;
2956
padding: 2;
3057
border: round $primary;
3158
background: $panel;
3259
}
60+
#queue-form-container {
61+
height: 1fr;
62+
max-height: 20;
63+
}
3364
.form-row {
3465
height: auto;
3566
margin-bottom: 1;
@@ -41,37 +72,262 @@ class TaskQueueModal(ModalScreen):
4172
.form-input {
4273
width: 100%;
4374
}
75+
Select {
76+
width: 100%;
77+
height: auto;
78+
min-height: 3;
79+
}
80+
Select > SelectCurrent {
81+
width: 1fr;
82+
height: auto;
83+
min-height: 1;
84+
padding: 0 1;
85+
}
86+
Select > SelectCurrent > Static {
87+
width: 1fr;
88+
}
89+
.form-switch {
90+
height: auto;
91+
}
4492
#queue-submit-btn {
4593
margin-top: 1;
4694
width: 100%;
4795
}
96+
#queue-spinner {
97+
height: 3;
98+
display: none;
99+
}
100+
#queue-status {
101+
text-align: center;
102+
height: auto;
103+
}
48104
"""
49105

50106
BINDINGS = [("escape", "dismiss", "Close")]
51107

52108
def __init__(self, task_info: dict) -> None:
53109
super().__init__()
54110
self.task_info = task_info
111+
self.param_widgets: dict[str, str] = {}
112+
self.providers: list[dict] = []
113+
self.selected_provider_id: str = task_info.get("provider_id", "")
55114

56115
def compose(self) -> ComposeResult:
57116
task_name = self.task_info.get("name", "Unknown")
117+
parameters = self.task_info.get("parameters", {})
118+
58119
with Vertical(id="queue-modal"):
59120
yield Label(f"[b]Queue Task: {task_name}[/b]")
60121

61122
with Vertical(classes="form-row"):
62-
yield Label("CPU Resources:", classes="form-label")
63-
yield Input(placeholder="e.g. 2", id="cpu-input", classes="form-input")
123+
yield Label("Provider:", classes="form-label")
124+
yield Select(
125+
[("Loading...", "_loading")],
126+
value="_loading",
127+
allow_blank=False,
128+
id="provider-select",
129+
classes="form-input",
130+
)
131+
# yield Label("[DEBUG] Selected: (none)", id="debug-provider-label")
64132

65-
with Vertical(classes="form-row"):
66-
yield Label("Memory Resources:", classes="form-label")
67-
yield Input(placeholder="e.g. 4GB", id="memory-input", classes="form-input")
133+
with ScrollableContainer(id="queue-form-container"):
134+
yield Label("[b]Task Parameters[/b]")
135+
if not parameters:
136+
yield Label("[dim]No configurable parameters[/dim]")
137+
else:
138+
for key, raw_value in parameters.items():
139+
schema = _normalize_param(key, raw_value)
140+
yield from self._render_param_field(key, schema)
68141

69142
yield Button("Queue", id="queue-submit-btn", variant="primary")
143+
yield LoadingIndicator(id="queue-spinner")
144+
yield Label("", id="queue-status")
145+
146+
def on_mount(self) -> None:
147+
self._fetch_providers_async()
148+
149+
@work(thread=True)
150+
def _fetch_providers_async(self) -> None:
151+
"""Fetch available compute providers from the API."""
152+
providers = fetch_providers()
153+
self.app.call_from_thread(self.populate_providers, providers)
154+
155+
def populate_providers(self, providers: list[dict]) -> None:
156+
"""Populate the provider select widget."""
157+
self.providers = providers
158+
provider_select = self.query_one("#provider-select", Select)
159+
160+
if not providers:
161+
with provider_select.prevent(Select.Changed):
162+
provider_select.set_options([("No providers available", "_none")])
163+
provider_select.value = "_none"
164+
return
165+
166+
options = [(p.get("name", p.get("id")), p.get("id")) for p in providers]
167+
168+
task_provider_id = self.task_info.get("provider_id", "")
169+
if task_provider_id and any(p.get("id") == task_provider_id for p in providers):
170+
initial_value = task_provider_id
171+
else:
172+
initial_value = providers[0].get("id")
173+
174+
with provider_select.prevent(Select.Changed):
175+
provider_select.set_options(options)
176+
provider_select.value = initial_value
177+
178+
self.selected_provider_id = initial_value
179+
# self._update_debug_label(initial_value)
180+
181+
# def _update_debug_label(self, value: str) -> None:
182+
# """Update the debug label with current selection."""
183+
# try:
184+
# debug_label = self.query_one("#debug-provider-label", Label)
185+
# provider_name = next((p.get("name") for p in self.providers if p.get("id") == value), value)
186+
# debug_label.update(f"[DEBUG] Selected: {provider_name} (id={value})")
187+
# except Exception:
188+
# pass
189+
190+
@on(Select.Changed, "#provider-select")
191+
def on_provider_changed(self, event: Select.Changed) -> None:
192+
if event.value != Select.BLANK:
193+
self.selected_provider_id = str(event.value)
194+
# self._update_debug_label(str(event.value))
195+
196+
def _render_param_field(self, key: str, schema: dict) -> ComposeResult:
197+
"""Render a single parameter field based on its schema."""
198+
param_type = schema.get("type", "string")
199+
title = schema.get("title", key)
200+
default = schema.get("default", "")
201+
ui_widget = schema.get("ui_widget")
202+
options = schema.get("options", [])
203+
204+
widget_id = f"param-{key}"
205+
self.param_widgets[key] = widget_id
206+
207+
with Vertical(classes="form-row"):
208+
yield Label(f"{title}:", classes="form-label")
209+
210+
if param_type == "bool":
211+
yield Switch(value=bool(default), id=widget_id, classes="form-switch")
212+
213+
elif param_type == "enum" or (ui_widget == "select" and options):
214+
select_options = [(opt, opt) for opt in options]
215+
yield Select(
216+
select_options,
217+
value=str(default) if default else Select.BLANK,
218+
id=widget_id,
219+
classes="form-input",
220+
)
221+
222+
elif ui_widget == "password":
223+
yield Input(
224+
value=str(default) if default else "",
225+
password=True,
226+
id=widget_id,
227+
classes="form-input",
228+
)
229+
230+
else:
231+
placeholder = ""
232+
if param_type == "int":
233+
placeholder = f"Integer (default: {default})"
234+
elif param_type == "float":
235+
placeholder = f"Float (default: {default})"
236+
else:
237+
placeholder = f"default: {default}" if default else ""
238+
239+
yield Input(
240+
value=str(default) if default else "",
241+
placeholder=placeholder,
242+
id=widget_id,
243+
classes="form-input",
244+
)
245+
246+
def _collect_param_values(self) -> dict:
247+
"""Collect current values from all parameter widgets."""
248+
values = {}
249+
parameters = self.task_info.get("parameters", {})
250+
251+
for key, raw_value in parameters.items():
252+
schema = _normalize_param(key, raw_value)
253+
widget_id = self.param_widgets.get(key)
254+
if not widget_id:
255+
continue
256+
257+
try:
258+
widget = self.query_one(f"#{widget_id}")
259+
except Exception:
260+
continue
261+
262+
if isinstance(widget, Switch):
263+
values[key] = widget.value
264+
elif isinstance(widget, Select):
265+
values[key] = widget.value if widget.value != Select.BLANK else schema.get("default", "")
266+
elif isinstance(widget, Input):
267+
raw = widget.value
268+
param_type = schema.get("type", "string")
269+
if param_type == "int":
270+
try:
271+
values[key] = int(raw) if raw else schema.get("default", 0)
272+
except ValueError:
273+
values[key] = schema.get("default", 0)
274+
elif param_type == "float":
275+
try:
276+
values[key] = float(raw) if raw else schema.get("default", 0.0)
277+
except ValueError:
278+
values[key] = schema.get("default", 0.0)
279+
else:
280+
values[key] = raw if raw else schema.get("default", "")
281+
282+
return values
70283

71284
def on_button_pressed(self, event: Button.Pressed) -> None:
72285
if event.button.id == "queue-submit-btn":
73-
self.notify("Queue functionality not implemented yet.", severity="information")
74-
self.dismiss()
286+
self._show_spinner(True, "Queuing task...")
287+
self.queue_task()
288+
289+
def _show_spinner(self, show: bool, message: str = "") -> None:
290+
"""Show or hide the spinner and update status message."""
291+
spinner = self.query_one("#queue-spinner", LoadingIndicator)
292+
status = self.query_one("#queue-status", Label)
293+
button = self.query_one("#queue-submit-btn", Button)
294+
295+
spinner.display = show
296+
button.display = not show
297+
status.update(message)
298+
299+
@work(thread=True)
300+
def queue_task(self) -> None:
301+
"""Queue the task by calling the provider launch endpoint."""
302+
param_values = self._collect_param_values()
303+
task = self.task_info
304+
305+
provider_id = self.selected_provider_id
306+
if not provider_id:
307+
self.app.call_from_thread(self._show_spinner, False)
308+
self.app.call_from_thread(self.notify, "Please select a provider before queuing.", severity="error")
309+
return
310+
311+
selected_provider = next((p for p in self.providers if p.get("id") == provider_id), None)
312+
provider_name = selected_provider.get("name") if selected_provider else task.get("provider_name")
313+
314+
payload = build_launch_payload(task, provider_name, param_values)
315+
316+
try:
317+
data = launch_task_on_provider(provider_id, payload)
318+
job_id = data.get("job_id", "unknown")
319+
self.app.call_from_thread(
320+
self.notify, f"Task queued successfully. Job ID: {job_id}", severity="information"
321+
)
322+
self.app.call_from_thread(self._dismiss_all_modals)
323+
except RuntimeError as e:
324+
self.app.call_from_thread(self._show_spinner, False)
325+
self.app.call_from_thread(self.notify, str(e), severity="error")
326+
327+
def _dismiss_all_modals(self) -> None:
328+
"""Dismiss this modal and the parent TaskListModal to return to homepage."""
329+
self.dismiss()
330+
self.app.pop_screen()
75331

76332

77333
class TaskListModal(ModalScreen):
@@ -101,8 +357,9 @@ class TaskListModal(ModalScreen):
101357

102358
BINDINGS = [("escape", "dismiss", "Close")]
103359

104-
def __init__(self) -> None:
360+
def __init__(self, experiment_id: str = "default") -> None:
105361
super().__init__()
362+
self.experiment_id = experiment_id
106363
self.tasks_data: list[dict] = []
107364

108365
def compose(self) -> ComposeResult:
@@ -121,7 +378,7 @@ def on_mount(self) -> None:
121378
@work(thread=True)
122379
def fetch_tasks(self) -> None:
123380
try:
124-
response = api.get("/tasks/list")
381+
response = api.get(f"/experiment/{self.experiment_id}/task/list")
125382
if response.status_code == 200:
126383
data = response.json()
127384
else:
@@ -145,11 +402,12 @@ def populate_tasks(self, tasks: list[dict]) -> None:
145402

146403
self.tasks_data = tasks
147404
option_list.clear_options()
405+
print("[DEBUG] Populating tasks:", tasks)
148406
for task in tasks:
149407
task_id = task.get("id", "?")
150408
task_name = task.get("name", "Unknown")
151-
task_type = task.get("type", "N/A")
152-
option_list.add_option(Option(f"{task_name} ({task_type})", id=task_id))
409+
# task_type = task.get("type", "N/A")
410+
option_list.add_option(Option(f"{task_name}", id=task_id))
153411

154412
@on(OptionList.OptionSelected, "#task-option-list")
155413
def on_task_selected(self, event: OptionList.OptionSelected) -> None:

0 commit comments

Comments
 (0)