Skip to content

Commit d2ba999

Browse files
committed
Improve process control widget
1 parent df0633b commit d2ba999

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

home/control.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
import psutil
1515
import traitlets as tr
1616
from aiida import engine, get_profile, manage, orm
17+
from aiida.common.exceptions import NotExistent
1718
from aiida.engine.daemon.client import DaemonException
19+
from aiida.engine.processes import control as process_control
1820
from sqlalchemy import text
1921

2022
from home import process
@@ -950,6 +952,7 @@ def worker():
950952
class ProcessControlWidget(ipw.VBox):
951953
def __init__(self):
952954
process_list = process.ProcessListWidget(path_to_root="../")
955+
self.process_list = process_list
953956
past_days_widget = ipw.IntText(value=7, description="Past days:")
954957
tr.link((past_days_widget, "value"), (process_list, "past_days"))
955958

@@ -970,16 +973,187 @@ def __init__(self):
970973
disabled=False,
971974
)
972975
tr.dlink((process_state_widget, "value"), (process_list, "process_states"))
976+
977+
self.process_select = ipw.SelectMultiple(
978+
description="Act on:",
979+
options=[],
980+
rows=8,
981+
layout=ipw.Layout(width="600px"),
982+
style={"description_width": "initial"},
983+
)
984+
self.process_select.observe(self._on_selection_change, names="value")
985+
process_list.observe(self._on_process_list_updated, names="updated")
986+
987+
self.pause_button = ipw.Button(description="Pause", disabled=True)
988+
self.pause_button.on_click(self._on_pause_clicked)
989+
self.play_button = ipw.Button(description="Play", disabled=True)
990+
self.play_button.on_click(self._on_play_clicked)
991+
self.kill_button = ipw.Button(
992+
description="Kill", button_style="danger", disabled=True
993+
)
994+
self.kill_button.on_click(self._on_kill_clicked)
995+
self._action_status = ipw.HTML()
996+
997+
self._action_running = False
998+
self._kill_armed = False
999+
9731000
process_list.update()
9741001

9751002
super().__init__(
9761003
children=[
9771004
ipw.HBox([past_days_widget, all_days_checkbox]),
9781005
process_state_widget,
9791006
process_list,
1007+
self.process_select,
1008+
ipw.HBox([self.pause_button, self.play_button, self.kill_button]),
1009+
self._action_status,
9801010
]
9811011
)
9821012

1013+
def _on_process_list_updated(self, _=None):
1014+
self._rebuild_options()
1015+
self._disarm_kill()
1016+
self._sync_action_buttons_disabled()
1017+
1018+
def _rebuild_options(self):
1019+
rows = self.process_list.current_rows.get("rows", [])
1020+
previous_selection = set(self.process_select.value)
1021+
1022+
options = []
1023+
for row in rows:
1024+
try:
1025+
pk = int(row[process.HEADER_PK])
1026+
except (KeyError, ValueError, TypeError):
1027+
continue
1028+
label = (
1029+
f"{pk} | {row.get(process.HEADER_PROCESS_LABEL, '')} | "
1030+
f"{row.get(process.HEADER_STATE, '')}"
1031+
)
1032+
options.append((label, pk))
1033+
1034+
self.process_select.options = options
1035+
self.process_select.value = tuple(
1036+
pk for _, pk in options if pk in previous_selection
1037+
)
1038+
1039+
def _on_selection_change(self, _=None):
1040+
self._disarm_kill()
1041+
self._sync_action_buttons_disabled()
1042+
1043+
def _sync_action_buttons_disabled(self):
1044+
disabled = self._action_running or not self.process_select.value
1045+
self.pause_button.disabled = disabled
1046+
self.play_button.disabled = disabled
1047+
self.kill_button.disabled = disabled
1048+
1049+
def _disarm_kill(self):
1050+
self._kill_armed = False
1051+
self.kill_button.description = "Kill"
1052+
1053+
def _on_pause_clicked(self, _=None):
1054+
self._disarm_kill()
1055+
self._dispatch_action(
1056+
action_name="pause the process(es)",
1057+
control_func=process_control.pause_processes,
1058+
verb="Pause",
1059+
)
1060+
1061+
def _on_play_clicked(self, _=None):
1062+
self._disarm_kill()
1063+
self._dispatch_action(
1064+
action_name="play the process(es)",
1065+
control_func=process_control.play_processes,
1066+
verb="Play",
1067+
)
1068+
1069+
def _on_kill_clicked(self, _=None):
1070+
selected = self.process_select.value
1071+
if not selected:
1072+
self._action_status.value = "Select at least one process."
1073+
return
1074+
if not self._kill_armed:
1075+
self._kill_armed = True
1076+
self.kill_button.description = f"Confirm kill ({len(selected)})"
1077+
return
1078+
self._dispatch_action(
1079+
action_name="kill the process(es)",
1080+
control_func=process_control.kill_processes,
1081+
verb="Kill",
1082+
)
1083+
self._disarm_kill()
1084+
1085+
def _dispatch_action(self, action_name, control_func, verb):
1086+
selected_pks = list(self.process_select.value)
1087+
if not selected_pks:
1088+
self._action_status.value = "Select at least one process."
1089+
return
1090+
1091+
if not engine.daemon.get_daemon_client().is_daemon_running:
1092+
self._action_status.value = (
1093+
"<span style='color:#b58900'>Process actions need the "
1094+
"running daemon (see the Daemon section above).</span>"
1095+
)
1096+
return
1097+
1098+
self._action_running = True
1099+
self._sync_action_buttons_disabled()
1100+
self._action_status.value = (
1101+
f"{verb} request in progress... <i class='fa fa-spinner fa-spin'></i>"
1102+
)
1103+
1104+
def worker():
1105+
try:
1106+
nodes = []
1107+
errors = []
1108+
for pk in selected_pks:
1109+
try:
1110+
nodes.append(orm.load_node(pk))
1111+
except NotExistent as exc:
1112+
errors.append(f"PK {pk}: {exc}")
1113+
1114+
if nodes:
1115+
control_func(nodes, timeout=5.0)
1116+
1117+
messages = []
1118+
if nodes:
1119+
messages.append(
1120+
f"<span style='color:green'>{verb} request sent to "
1121+
f"{len(nodes)} process(es). States may take a few "
1122+
"seconds to change.</span>"
1123+
)
1124+
if errors:
1125+
messages.append(
1126+
"<span style='color:red'>"
1127+
+ "<br>".join(html.escape(e) for e in errors)
1128+
+ "</span>"
1129+
)
1130+
self._action_status.value = "<br>".join(messages) or "Nothing to do."
1131+
except process_control.ProcessTimeoutException as exc:
1132+
self._action_status.value = (
1133+
f"<span style='color:red'>Timed out trying to "
1134+
f"{action_name}: {html.escape(str(exc))}</span>"
1135+
)
1136+
except Exception as exc:
1137+
self._action_status.value = (
1138+
f"<span style='color:red'>Failed to {action_name}: "
1139+
f"{html.escape(str(exc))}</span>"
1140+
)
1141+
finally:
1142+
# Re-enable the buttons before refreshing the process list:
1143+
# if the refresh raises, the page must not be left with the
1144+
# buttons permanently disabled.
1145+
self._action_running = False
1146+
self._sync_action_buttons_disabled()
1147+
try:
1148+
self.process_list.update()
1149+
except Exception as exc:
1150+
self._action_status.value += (
1151+
"<br><span style='color:red'>Failed to refresh the "
1152+
f"process list: {html.escape(str(exc))}</span>"
1153+
)
1154+
1155+
threading.Thread(target=worker, daemon=True).start()
1156+
9831157

9841158
class GroupControlWidget(ipw.VBox):
9851159
def __init__(self):

home/process.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ def __init__(self, function):
7373
)
7474

7575

76+
# Header labels produced by `CalculationQueryBuilder.get_projected` for the
77+
# projections used in `ProcessListWidget.update`; named here so callers (e.g.
78+
# `home.control`) don't hardcode these strings.
79+
HEADER_PK = "PK"
80+
HEADER_PROCESS_LABEL = "Process label"
81+
HEADER_STATE = "Process State"
82+
83+
7684
def _stringify_process_cell(value):
7785
if value is None:
7886
return ""
@@ -626,11 +634,13 @@ class ProcessListWidget(ipw.VBox):
626634
process_states = tl.List()
627635
process_label = tl.Unicode(allow_none=True)
628636
description_contains = tl.Unicode(allow_none=True)
637+
updated = tl.Int(0)
629638

630639
def __init__(self, path_to_root="../", **kwargs):
631640
self.path_to_root = path_to_root
632641
self.table = ipw.HTML()
633642
self.output = ipw.HTML()
643+
self.current_rows = {"headers": [], "rows": []}
634644
update_button = ipw.Button(description="Update now")
635645
update_button.on_click(self.update)
636646
super().__init__(
@@ -684,11 +694,14 @@ def update(self, _=None):
684694
# Keep only process that contain the requested string in the description.
685695
rows = _filter_process_rows(rows, self.description_contains)
686696

697+
self.current_rows = {"headers": headers, "rows": rows}
698+
687699
self.output.value = f"{len(rows)} processes shown"
688700

689701
# Add HTML links.
690702
rows = _add_process_links(rows, self.path_to_root)
691703
self.table.value = _render_process_table(headers, rows)
704+
self.updated += 1
692705
except Exception as exc:
693706
self.output.value = (
694707
f'<span style="color:red">Failed to update process list: {exc}</span>'

0 commit comments

Comments
 (0)