ms-components 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (140) hide show
  1. {ms_components-0.4.2 → ms_components-0.4.3}/PKG-INFO +1 -1
  2. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_executors.py +83 -2
  3. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/job_cards.py +58 -30
  4. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/models.py +3 -1
  5. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/polling.py +3 -0
  6. ms_components-0.4.3/src/ms_components/ms_monitor/resources.py +305 -0
  7. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/widget.py +21 -10
  8. ms_components-0.4.3/src/ms_components/ms_pymol/command_line.py +136 -0
  9. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/widget.py +9 -0
  10. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/controller.py +21 -3
  11. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/overlay.py +8 -2
  12. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/PKG-INFO +1 -1
  13. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/SOURCES.txt +3 -0
  14. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/scm_file_list.json +3 -0
  15. ms_components-0.4.3/src/ms_components.egg-info/scm_version.json +8 -0
  16. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_executors_ui.py +3 -0
  17. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_molsuite_monitor_widgets.py +18 -1
  18. ms_components-0.4.3/tests/test_pymol_command_line.py +48 -0
  19. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_tutorial.py +64 -0
  20. ms_components-0.4.2/src/ms_components.egg-info/scm_version.json +0 -8
  21. {ms_components-0.4.2 → ms_components-0.4.3}/.github/workflows/docs.yml +0 -0
  22. {ms_components-0.4.2 → ms_components-0.4.3}/.github/workflows/publish.yml +0 -0
  23. {ms_components-0.4.2 → ms_components-0.4.3}/.gitignore +0 -0
  24. {ms_components-0.4.2 → ms_components-0.4.3}/LICENSE +0 -0
  25. {ms_components-0.4.2 → ms_components-0.4.3}/MANIFEST.in +0 -0
  26. {ms_components-0.4.2 → ms_components-0.4.3}/README.md +0 -0
  27. {ms_components-0.4.2 → ms_components-0.4.3}/examples/tutorial_demo.py +0 -0
  28. {ms_components-0.4.2 → ms_components-0.4.3}/examples/tutorial_demo.toml +0 -0
  29. {ms_components-0.4.2 → ms_components-0.4.3}/licenses/qt-themes-LICENSE.txt +0 -0
  30. {ms_components-0.4.2 → ms_components-0.4.3}/pyproject.toml +0 -0
  31. {ms_components-0.4.2 → ms_components-0.4.3}/setup.cfg +0 -0
  32. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/__init__.py +0 -0
  33. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/backdrop.py +0 -0
  34. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/base.qss +0 -0
  35. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/chips.py +0 -0
  36. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ketcher.py +0 -0
  37. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/listview.py +0 -0
  38. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  39. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  40. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  41. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  42. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/widget.py +0 -0
  43. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/__init__.py +0 -0
  44. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/bridge.py +0 -0
  45. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/cards.py +0 -0
  46. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/config.py +0 -0
  47. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  48. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/formatting.py +0 -0
  49. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/palette.py +0 -0
  50. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/__init__.py +0 -0
  51. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/app.py +0 -0
  52. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/backend.py +0 -0
  53. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/browser.py +0 -0
  54. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/scratch.py +0 -0
  55. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/widget.py +0 -0
  56. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/__init__.py +0 -0
  57. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  58. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  59. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  60. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  61. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  62. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +0 -0
  63. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hydrogens.svg +0 -0
  64. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  65. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  66. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  67. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  68. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  69. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  70. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/style.svg +0 -0
  71. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  72. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/plugins.py +0 -0
  73. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/scene.py +0 -0
  74. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_settings/__init__.py +0 -0
  75. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_settings/panel.py +0 -0
  76. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_stepper.py +0 -0
  77. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/__init__.py +0 -0
  78. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/action_header.py +0 -0
  79. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/cell_delegate.py +0 -0
  80. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/column_panel.py +0 -0
  81. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/example_usage.py +0 -0
  82. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/filter_panel.py +0 -0
  83. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/columns.svg +0 -0
  84. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/export.svg +0 -0
  85. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/filter.svg +0 -0
  86. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/menu.svg +0 -0
  87. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/reload.svg +0 -0
  88. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/search.svg +0 -0
  89. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/settings.svg +0 -0
  90. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  91. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  92. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort.svg +0 -0
  93. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  94. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/pagination_control.py +0 -0
  95. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/query_builder.py +0 -0
  96. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/session_provider.py +0 -0
  97. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/smart_table_view.py +0 -0
  98. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/sort_panel.py +0 -0
  99. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_config.py +0 -0
  100. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_interaction.py +0 -0
  101. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_model.py +0 -0
  102. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/test_external_filters.py +0 -0
  103. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/test_toolbar.py +0 -0
  104. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/__init__.py +0 -0
  105. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/bindings.py +0 -0
  106. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/models.py +0 -0
  107. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/opengl.py +0 -0
  108. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/pagination.py +0 -0
  109. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/palettes.py +0 -0
  110. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/step_dialog.py +0 -0
  111. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/theme.py +0 -0
  112. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/tool_panel.py +0 -0
  113. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/wheel.py +0 -0
  114. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/dependency_links.txt +0 -0
  115. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/requires.txt +0 -0
  116. {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/top_level.txt +0 -0
  117. {ms_components-0.4.2 → ms_components-0.4.3}/tests/conftest.py +0 -0
  118. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_app_settings.py +0 -0
  119. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_chips.py +0 -0
  120. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_dock_window.py +0 -0
  121. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_ketcher.py +0 -0
  122. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_molsuite_monitor_bridge.py +0 -0
  123. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_monitor_config.py +0 -0
  124. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_ms_projects_widget.py +0 -0
  125. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_opengl.py +0 -0
  126. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_progress_column.py +0 -0
  127. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_project_browser.py +0 -0
  128. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_grid.py +0 -0
  129. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_scene.py +0 -0
  130. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_toolbar.py +0 -0
  131. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_query_builder_keyset.py +0 -0
  132. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_shift_hscroll.py +0 -0
  133. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_split_button_style.py +0 -0
  134. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_step_dialog.py +0 -0
  135. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_interaction.py +0 -0
  136. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_toolbar.py +0 -0
  137. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_view_state.py +0 -0
  138. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_theme_contrast.py +0 -0
  139. {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_tool_panel.py +0 -0
  140. {ms_components-0.4.2 → ms_components-0.4.3}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: PySide6 UI components for MolSuite: smart table, project browser, job monitor, PyMOL dock, steppers.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -8,7 +8,10 @@ be done now, and says why.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import shlex
12
+ import sys
11
13
  import threading
14
+ import time
12
15
  import types
13
16
  from typing import Any, Literal, Union, get_args, get_origin
14
17
 
@@ -90,6 +93,44 @@ def _value(editor: QWidget) -> Any:
90
93
  return editor.text().strip()
91
94
 
92
95
 
96
+ _VERSIONS = """import sys, importlib.metadata as m
97
+ print("Python", sys.version.split()[0], "at", sys.prefix)
98
+ for name in sys.argv[1:]:
99
+ try:
100
+ print(f" {name} {m.version(name)}")
101
+ except m.PackageNotFoundError:
102
+ print(f" {name}: not installed")
103
+ """
104
+
105
+
106
+ def describe_host(address: str, env: str = "", packages=(), tools=None) -> str:
107
+ """What the machine `address` (empty: this one) is and has, in a few lines: its system, CPUs,
108
+ memory and GPUs, the Python of `env` there with the version of each of `packages`, and whether
109
+ each of `tools` (name -> path) can be run. One `ssh` call, which raises `HostError` when it does
110
+ not get in. Blocks: not on the GUI thread."""
111
+ from ms_env import Host
112
+
113
+ tools = tools or {}
114
+ python = f"{env.rstrip('/')}/bin/python" if env else ("" if address else sys.executable)
115
+ script = "; ".join([
116
+ "export LC_ALL=C", "uname -srm", '(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
117
+ 'echo "CPUs: $(nproc 2>/dev/null || sysctl -n hw.ncpu)"',
118
+ "awk '/MemTotal/ {printf \"Memory: %.1f GB\\n\", $2/1048576}' /proc/meminfo 2>/dev/null",
119
+ "(nvidia-smi -L 2>/dev/null || echo 'GPUs: none seen by nvidia-smi')",
120
+ "echo",
121
+ *([f"if [ -x {shlex.quote(python)} ]; then {shlex.quote(python)} -c {shlex.quote(_VERSIONS)} "
122
+ f"{' '.join(map(shlex.quote, packages))}; else echo 'Environment: none at '{shlex.quote(python)}; fi"]
123
+ if python else ["echo 'Environment: none named; the app prepares one before the first job'"]),
124
+ *(f"([ -x {shlex.quote(path)} ] && echo ' ✓ {name} '{shlex.quote(path)} || echo ' ✗ {name} not found at '{shlex.quote(path)})"
125
+ for name, path in tools.items()),
126
+ ])
127
+ if tools:
128
+ script = script.replace("; ([ -x", "; echo 'Tools:'; ([ -x", 1)
129
+ began = time.monotonic()
130
+ said = Host(address or None).run(script, timeout=60).decode(errors="replace").strip()
131
+ return f"Connected to {address or 'this machine'} in {time.monotonic() - began:.1f} s\n\n{said}"
132
+
133
+
93
134
  class KeptPage(QWidget):
94
135
  """The entries kept in `table` (`hosts` or `executors`) and the form of the chosen one.
95
136
 
@@ -98,10 +139,15 @@ class KeptPage(QWidget):
98
139
  """
99
140
 
100
141
  changed = Signal()
142
+ _checked = Signal(str)
101
143
 
102
- def __init__(self, workspace, table: str, parent: QWidget | None = None):
144
+ def __init__(self, workspace, table: str, parent: QWidget | None = None, *, packages=("ms_flow", "ms_env"),
145
+ default_env: str = ""):
146
+ """`packages`: whose version "Check connection" reads on a host; `default_env`: the
147
+ environment the app prepares on a host that names none, under the home there."""
103
148
  super().__init__(parent)
104
149
  self._workspace, self._table, self._one = workspace, table, table[:-1]
150
+ self._packages, self._default_env = tuple(packages), default_env
105
151
  settings = type(workspace.settings_manager.settings)
106
152
  self._model = get_args(settings.model_fields[table].annotation)[1]
107
153
  self._editors: dict[str, QWidget] = {}
@@ -130,6 +176,17 @@ class KeptPage(QWidget):
130
176
  self.save_button.clicked.connect(self._save)
131
177
  foot = QHBoxLayout()
132
178
  foot.addWidget(self.said, 1)
179
+ if table == "hosts":
180
+ self.check_button = QPushButton("Check connection", self)
181
+ self.check_button.setToolTip("Log in to it and say what it is and what it has: system, CPUs, GPUs, "
182
+ "the environment of the app and its packages, the tools named.")
183
+ self.check_button.clicked.connect(self._check)
184
+ foot.addWidget(self.check_button)
185
+ self.report = QPlainTextEdit(self)
186
+ self.report.setReadOnly(True)
187
+ self.report.setVisible(False)
188
+ self._right.addWidget(self.report, 8) # the room the stretch below has while nothing is said
189
+ self._checked.connect(self._show_report)
133
190
  foot.addWidget(self.save_button)
134
191
  self._right.addStretch(1)
135
192
  self._right.addLayout(foot)
@@ -187,6 +244,8 @@ class KeptPage(QWidget):
187
244
  self._only_of_kind()
188
245
  self.remove_button.setEnabled(bool(name) and name != "local")
189
246
  self.said.setText("")
247
+ if self._table == "hosts":
248
+ self.report.setVisible(False)
190
249
  self._right.insertWidget(0, body)
191
250
 
192
251
  def _only_of_kind(self) -> None:
@@ -226,6 +285,28 @@ class KeptPage(QWidget):
226
285
  self.said.setText(f"`{name}` is kept.")
227
286
  self.changed.emit()
228
287
 
288
+ def _check(self) -> None:
289
+ """What `describe_host` says of the host as the form has it now, saved or not."""
290
+ fields = {key: _value(editor) for key, editor in self._editors.items()}
291
+ address = fields.get("address") or ""
292
+ env = fields.get("env") or (self._default_env if address else "")
293
+ self.check_button.setEnabled(False)
294
+ self._show_report(f"Connecting to {address or 'this machine'}…")
295
+
296
+ def work() -> None:
297
+ try:
298
+ said = describe_host(address, env, self._packages, fields.get("tools") or {})
299
+ except Exception as error: # noqa: BLE001 - said in the page, where it was asked for
300
+ said = f"Could not check {address or 'this machine'}:\n{str(error).split('` on ', 1)[-1]}"
301
+ self._checked.emit(said)
302
+
303
+ threading.Thread(target=work, name="host-check", daemon=True).start()
304
+
305
+ def _show_report(self, text: str) -> None:
306
+ self.report.setPlainText(text)
307
+ self.report.setVisible(True)
308
+ self.check_button.setEnabled(not text.endswith("…"))
309
+
229
310
  def _remove(self) -> None:
230
311
  name = self.name.text().strip()
231
312
  asked = QMessageBox.question(self, "Remove", f"Remove `{name}` from the {self._table} kept?")
@@ -359,4 +440,4 @@ class ExecutorsPanel(QDialog):
359
440
  self.refresh()
360
441
 
361
442
 
362
- __all__ = ["ExecutorsPanel", "KeptPage"]
443
+ __all__ = ["ExecutorsPanel", "KeptPage", "describe_host"]
@@ -7,8 +7,8 @@ panel. The card shows the job-level rollup; unfolded, the tasks in flight under
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- from PySide6.QtCore import Qt, QPoint, QPropertyAnimation, QEasingCurve, Property, Signal
11
- from PySide6.QtGui import QBrush, QColor, QCursor, QLinearGradient, QPainter
10
+ from PySide6.QtCore import Qt, QPoint, QPointF, QPropertyAnimation, QEasingCurve, Property, Signal
11
+ from PySide6.QtGui import QBrush, QColor, QCursor, QLinearGradient, QPainter, QPalette, QPen
12
12
  from PySide6.QtWidgets import (
13
13
  QHBoxLayout,
14
14
  QLabel,
@@ -164,6 +164,39 @@ class GlowBar(QWidget):
164
164
  p.drawRoundedRect(0, 0, fw, h, 3, 3)
165
165
 
166
166
 
167
+ class _GlyphButton(QPushButton):
168
+ """A small flat button whose glyph is painted, not a font character: `ⓘ` and `▶` came out
169
+ in whatever font (or emoji) the system had, ringed twice, and blind to the theme."""
170
+
171
+ def __init__(self, glyph: str, tip: str, parent=None):
172
+ super().__init__(parent)
173
+ self._glyph = glyph # "info", or "chevron": right while unchecked, down once checked
174
+ self.setFixedSize(22, 22)
175
+ self.setCursor(QCursor(Qt.PointingHandCursor))
176
+ self.setToolTip(tip)
177
+ self.setStyleSheet(
178
+ """QPushButton { background:transparent; border:1px solid transparent; border-radius:4px; }
179
+ QPushButton:hover, QPushButton:checked { border-color:palette(mid); background:palette(alternate-base); }"""
180
+ )
181
+
182
+ def paintEvent(self, event):
183
+ super().paintEvent(event)
184
+ p = QPainter(self)
185
+ p.setRenderHint(QPainter.Antialiasing)
186
+ role = QPalette.ColorRole.Highlight if self.underMouse() or self.isChecked() else QPalette.ColorRole.PlaceholderText
187
+ pen = QPen(self.palette().color(role), 1.6, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
188
+ p.setPen(pen)
189
+ c = QPointF(self.width() / 2, self.height() / 2)
190
+ if self._glyph == "info":
191
+ p.drawEllipse(c, 7, 7)
192
+ p.drawLine(QPointF(c.x(), c.y() - 0.5), QPointF(c.x(), c.y() + 3.5))
193
+ p.drawPoint(QPointF(c.x(), c.y() - 3.2))
194
+ else:
195
+ d = 3.5
196
+ points = ((-d / 2, -d), (d / 2, 0), (-d / 2, d)) if not self.isChecked() else ((-d, -d / 2), (0, d / 2), (d, -d / 2))
197
+ p.drawPolyline([QPointF(c.x() + x, c.y() + y) for x, y in points])
198
+
199
+
167
200
  _TASKS_SHOWN = 12 # rows under an unfolded job; the rest are counted
168
201
 
169
202
 
@@ -227,6 +260,7 @@ class JobCard(QWidget):
227
260
 
228
261
  def _setup_ui(self):
229
262
  self.setObjectName("JobCard")
263
+ self.setAttribute(Qt.WA_StyledBackground, True) # a plain QWidget paints no QSS background or border
230
264
  self.setCursor(QCursor(Qt.PointingHandCursor))
231
265
  self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
232
266
  self._apply_card_style()
@@ -262,28 +296,12 @@ class JobCard(QWidget):
262
296
  )
263
297
  top.addWidget(self._eta_lbl)
264
298
 
265
- self._info_btn = QPushButton("ⓘ")
266
- self._info_btn.setFixedSize(20, 20)
267
- self._info_btn.setCursor(QCursor(Qt.PointingHandCursor))
268
- self._info_btn.setStyleSheet(
269
- f"""QPushButton {{ background:transparent; color:palette(placeholder-text);
270
- border:1px solid palette(mid); border-radius:10px; font-size:11px; }}
271
- QPushButton:hover {{ color:palette(highlight); border-color:palette(highlight);
272
- background:palette(alternate-base); }}"""
273
- )
299
+ self._info_btn = _GlyphButton("info", "Details of the job")
274
300
  self._info_btn.clicked.connect(self._show_info_tooltip)
275
301
  top.addWidget(self._info_btn)
276
302
 
277
- self._toggle = QPushButton("▶")
303
+ self._toggle = _GlyphButton("chevron", "Show the tasks in flight")
278
304
  self._toggle.setCheckable(True)
279
- self._toggle.setFixedSize(20, 20)
280
- self._toggle.setCursor(QCursor(Qt.PointingHandCursor))
281
- self._toggle.setToolTip("Show the tasks in flight")
282
- self._toggle.setStyleSheet(
283
- f"""QPushButton {{ background:transparent; color:palette(placeholder-text);
284
- border:1px solid palette(mid); border-radius:4px; font-size:9px; }}
285
- QPushButton:hover {{ color:palette(highlight); border-color:palette(highlight); }}"""
286
- )
287
305
  self._toggle.toggled.connect(self._on_toggled)
288
306
  top.addWidget(self._toggle)
289
307
 
@@ -313,7 +331,6 @@ class JobCard(QWidget):
313
331
 
314
332
  def _on_toggled(self, unfolded: bool):
315
333
  self._tasks_wrap.setVisible(unfolded)
316
- self._toggle.setText("▼" if unfolded else "▶")
317
334
  self._refresh_tasks()
318
335
  self.unfolded.emit(self._job.job_id, unfolded)
319
336
 
@@ -335,12 +352,16 @@ class JobCard(QWidget):
335
352
  self._tasks_note.setVisible(bool(more) or not shown)
336
353
 
337
354
  def _apply_card_style(self):
338
- border = P["accent"] if self._selected else P["border"]
339
- self.setStyleSheet(
340
- f"""#JobCard {{ background:palette(base); border:1px solid {border};
341
- border-radius:8px; }}
342
- #JobCard:hover {{ border-color:palette(mid); }}"""
343
- )
355
+ # Selected: tinted with the highlight and ringed by it, which hovering does not undo.
356
+ if self._selected:
357
+ tint = QColor(P["accent"])
358
+ tint.setAlphaF(0.12)
359
+ rule = f"background:{tint.name(QColor.HexArgb)}; border:2px solid {P['accent']};"
360
+ hover = ""
361
+ else:
362
+ rule = "background:palette(base); border:2px solid palette(mid);"
363
+ hover = f"#JobCard:hover {{ border-color:{P['accent']}; }}"
364
+ self.setStyleSheet(f"#JobCard {{ {rule} border-radius:8px; }} {hover}")
344
365
 
345
366
  def set_selected(self, selected: bool):
346
367
  if selected == self._selected:
@@ -369,12 +390,18 @@ class JobCard(QWidget):
369
390
  parts = []
370
391
  if job.chunks_done:
371
392
  parts.append(f"<span style='color:{P['ok']}'>{job.chunks_done:,} ✓</span>")
372
- if job.chunks_running:
373
- parts.append(f"<span style='color:{P['accent']}'>{job.chunks_running} ⚙</span>")
393
+ if job.tasks_running:
394
+ parts.append(f"<span style='color:{P['accent']}'>{job.tasks_running} ⚙</span>")
395
+ waiting = job.chunks_running - job.tasks_running # ponytail: packed chunks count here too
396
+ if waiting > 0:
397
+ parts.append(f"<span style='color:{P['text_muted']}'>{waiting:,} ⧗</span>")
374
398
  if job.chunks_failed:
375
399
  parts.append(f"<span style='color:{P['error']}'>{job.chunks_failed:,} ✗</span>")
376
400
  total = f" / {job.chunks_total:,}" if job.chunks_total else ""
377
401
  self._counters_lbl.setText(" · ".join(parts) + total)
402
+ self._counters_lbl.setToolTip(
403
+ "✓ done · ⚙ tasks running now, one worker each · ⧗ chunks taken that do not run: "
404
+ "carried in a task with others, or done and waiting to be written · ✗ failed")
378
405
  if self._toggle.isChecked():
379
406
  self._refresh_tasks()
380
407
 
@@ -386,7 +413,8 @@ class JobCard(QWidget):
386
413
  f"<hr style='border-color:{P['border']}'>"
387
414
  f"<b>Key:</b> {j.job_id}<br>"
388
415
  f"<b>Chunks:</b> {j.chunks_done:,} ✓ / {total} total<br>"
389
- f"<b>Running:</b> {j.chunks_running} "
416
+ f"<b>Tasks running:</b> {j.tasks_running} on {j.cpu_used} CPU "
417
+ f"<b>Chunks taken:</b> {j.chunks_running:,}<br>"
390
418
  f"<b>Failed:</b> {j.chunks_failed:,} "
391
419
  f"<b>Retried:</b> {j.chunks_retried:,}<br>"
392
420
  f"<b>Peak memory:</b> {j.peak_mem_mb or '—'} MB<br>"
@@ -43,7 +43,9 @@ class JobMonitorState:
43
43
  chunks_total: int = 0 # 0 while the source of chunks has not said
44
44
  chunks_done: int = 0
45
45
  chunks_failed: int = 0
46
- chunks_running: int = 0
46
+ chunks_running: int = 0 # taken and not settled: running, carried in a task, or being written
47
+ tasks_running: int = 0 # what runs now, one worker each; <= chunks_running
48
+ cpu_used: int = 0 # the CPU tokens those tasks hold
47
49
  chunks_retried: int = 0
48
50
  peak_mem_mb: int = 0
49
51
  throughput_eps: float = 0.0
@@ -23,6 +23,9 @@ def job_state(job, now: float) -> JobMonitorState:
23
23
  chunks_done=job.chunks_done,
24
24
  chunks_failed=job.chunks_failed,
25
25
  chunks_running=job.chunks_inflight,
26
+ # ms_flow < 0.7 does not tell them apart: every chunk in flight reads as running.
27
+ tasks_running=getattr(job, "tasks_running", job.chunks_inflight),
28
+ cpu_used=getattr(job, "cpu_used", 0),
26
29
  chunks_retried=job.chunks_retried,
27
30
  peak_mem_mb=job.peak_mem_mb,
28
31
  # ponytail: the mean since the job started, not the rate right now; a rate over the
@@ -0,0 +1,305 @@
1
+ """What the machines that run jobs are using: CPU, memory and GPU of each host, over the last minutes.
2
+
3
+ A host is read where it is: this machine with `psutil` (or `/proc`), another one through
4
+ `ms_env.Host` (the system's `ssh`), with one command that prints `/proc/stat`, `/proc/meminfo`
5
+ and `nvidia-smi`. The CPU in use is the change of `/proc/stat` between two reads, so the first
6
+ read of a host shows none. Each host is read in its own thread, only while the panel is shown,
7
+ every `interval` seconds: a few for this machine, more for one over the network, the most for
8
+ the login node of a queue, which is asked through the same `ssh` its jobs are.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+
19
+ from PySide6.QtCore import QObject, QPointF, Qt, Signal
20
+ from PySide6.QtGui import QColor, QPainter, QPainterPath, QPalette, QPen
21
+ from PySide6.QtWidgets import QFrame, QGridLayout, QLabel, QScrollArea, QToolTip, QVBoxLayout, QWidget
22
+
23
+ # Seconds between two reads, by the kind of executor the host is under.
24
+ INTERVALS = {"local": 5, "ray": 15, "queue": 60}
25
+ _HISTORY = 120 # reads kept per host: 10 min of this machine, 30 of one over the network
26
+
27
+ _GPU_QUERY = "nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits"
28
+ _SCRIPT = f"head -1 /proc/stat; grep -E '^(MemTotal|MemAvailable):' /proc/meminfo; {_GPU_QUERY} 2>/dev/null || true"
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class Sample:
33
+ cpu: float | None = None # percent of all CPUs; None on the first read
34
+ mem: float = 0.0 # percent
35
+ mem_used_gb: float = 0.0
36
+ mem_total_gb: float = 0.0
37
+ gpus: list[tuple[float, float, float]] = field(default_factory=list) # (util %, MiB used, MiB total)
38
+ error: str = ""
39
+
40
+
41
+ def parse(text: str, previous: tuple[int, int] | None) -> tuple[Sample, tuple[int, int] | None]:
42
+ """What `_SCRIPT` printed, and the CPU counters it read (busy, total) for the next read."""
43
+ sample, ticks, mem = Sample(), None, {}
44
+ for line in text.splitlines():
45
+ if line.startswith("cpu "):
46
+ values = [int(v) for v in line.split()[1:]]
47
+ idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait
48
+ ticks = (sum(values) - idle, sum(values))
49
+ elif line.startswith(("MemTotal:", "MemAvailable:")):
50
+ key, value = line.split(":")
51
+ mem[key] = int(value.split()[0]) / 1024**2 # kB → GB
52
+ elif line.count(",") == 2:
53
+ try:
54
+ sample.gpus.append(tuple(float(v) for v in line.split(",")))
55
+ except ValueError:
56
+ pass
57
+ if ticks and previous and ticks[1] > previous[1]:
58
+ sample.cpu = 100.0 * (ticks[0] - previous[0]) / (ticks[1] - previous[1])
59
+ total = mem.get("MemTotal", 0.0)
60
+ if total:
61
+ sample.mem_total_gb, sample.mem_used_gb = total, total - mem.get("MemAvailable", total)
62
+ sample.mem = 100.0 * sample.mem_used_gb / total
63
+ return sample, ticks
64
+
65
+
66
+ def _local_gpus() -> list[tuple[float, float, float]]:
67
+ if not shutil.which("nvidia-smi"):
68
+ return []
69
+ try:
70
+ out = subprocess.run(_GPU_QUERY.split(), capture_output=True, text=True, timeout=10).stdout
71
+ except (OSError, subprocess.SubprocessError):
72
+ return []
73
+ return parse(out, None)[0].gpus
74
+
75
+
76
+ class _Reader:
77
+ """Reads one host; keeps what the next read needs."""
78
+
79
+ def __init__(self, address: str):
80
+ self.address, self._ticks, self._psutil = address, None, None
81
+ if not address:
82
+ try:
83
+ import psutil
84
+
85
+ psutil.cpu_percent(None) # the first call only starts the count
86
+ self._psutil = psutil
87
+ except ImportError:
88
+ pass
89
+
90
+ def read(self) -> Sample:
91
+ try:
92
+ if self._psutil is not None:
93
+ memory = self._psutil.virtual_memory()
94
+ return Sample(self._psutil.cpu_percent(None), memory.percent, (memory.total - memory.available) / 1024**3,
95
+ memory.total / 1024**3, _local_gpus())
96
+ from ms_env import Host
97
+
98
+ text = Host(self.address or None).run(_SCRIPT, timeout=30).decode(errors="replace")
99
+ sample, self._ticks = parse(text, self._ticks)
100
+ return sample
101
+ except Exception as error: # noqa: BLE001 - shown on the row of the host
102
+ said = str(error).split("` on ", 1)[-1].strip() # not the command: where, and what went wrong
103
+ return Sample(error=said.splitlines()[-1] if said else type(error).__name__)
104
+
105
+
106
+ class Sparkline(QWidget):
107
+ """One measure of one host over time, 0-100 %: the line, the area under it and the value now.
108
+ Hovering says the value under the pointer and how long ago it was."""
109
+
110
+ def __init__(self, title: str, parent=None):
111
+ super().__init__(parent)
112
+ self.title, self.values, self.interval, self.note = title, deque(maxlen=_HISTORY), 5, ""
113
+ self.setMinimumSize(160, 64)
114
+ self.setMouseTracking(True)
115
+
116
+ def push(self, value: float | None, note: str = "") -> None:
117
+ self.values.append(value)
118
+ self.note = note
119
+ self.update()
120
+
121
+ def _x(self, index: int) -> float:
122
+ return self.width() * index / max(1, _HISTORY - 1)
123
+
124
+ def paintEvent(self, _):
125
+ p = QPainter(self)
126
+ p.setRenderHint(QPainter.Antialiasing)
127
+ pal = self.palette()
128
+ w, h, top = self.width(), self.height(), 14
129
+ p.setPen(QPen(pal.color(QPalette.ColorRole.Mid), 1))
130
+ p.drawLine(0, h - 1, w, h - 1) # the 0 %, recessive
131
+ p.setPen(pal.color(QPalette.ColorRole.PlaceholderText))
132
+ p.drawText(0, 0, w, top, Qt.AlignLeft | Qt.AlignTop, self.title)
133
+ now = next((v for v in reversed(self.values) if v is not None), None)
134
+ p.setPen(pal.color(QPalette.ColorRole.Text))
135
+ p.drawText(0, 0, w, top, Qt.AlignRight | Qt.AlignTop,
136
+ "—" if now is None else f"{now:.0f}%" + (f" {self.note}" if self.note else ""))
137
+ start = _HISTORY - len(self.values)
138
+ points = [QPointF(self._x(start + i), h - 1 - (h - top - 2) * min(100.0, v) / 100.0)
139
+ for i, v in enumerate(self.values) if v is not None]
140
+ if len(points) < 2:
141
+ return
142
+ accent = pal.color(QPalette.ColorRole.Highlight)
143
+ area = QPainterPath(QPointF(points[0].x(), h - 1))
144
+ for point in points:
145
+ area.lineTo(point)
146
+ area.lineTo(QPointF(points[-1].x(), h - 1))
147
+ fill = QColor(accent)
148
+ fill.setAlphaF(0.18)
149
+ p.fillPath(area, fill)
150
+ p.setPen(QPen(accent, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
151
+ p.drawPolyline(points)
152
+
153
+ def mouseMoveEvent(self, event):
154
+ index = round(event.position().x() / max(1.0, self.width()) * (_HISTORY - 1)) - (_HISTORY - len(self.values))
155
+ if 0 <= index < len(self.values) and self.values[index] is not None:
156
+ ago = (len(self.values) - 1 - index) * self.interval
157
+ QToolTip.showText(event.globalPosition().toPoint(),
158
+ f"{self.title}: {self.values[index]:.0f}% · {ago}s ago" if ago else f"{self.title}: {self.values[index]:.0f}% · now", self)
159
+ else:
160
+ QToolTip.hideText()
161
+
162
+
163
+ class _Relay(QObject):
164
+ sampled = Signal(str, object) # host key, Sample
165
+
166
+
167
+ class _HostRow(QFrame):
168
+ def __init__(self, name: str, address: str, interval: int, parent=None):
169
+ super().__init__(parent)
170
+ self.setObjectName("hostRow")
171
+ self.setStyleSheet("QFrame#hostRow { background:palette(base); border:1px solid palette(mid); border-radius:8px; }")
172
+ grid = QGridLayout(self)
173
+ grid.setContentsMargins(10, 6, 10, 6)
174
+ title = QLabel(f"<b>{name}</b> <span style='color:palette(placeholder-text)'>"
175
+ f"{address or 'this computer'} · every {interval}s</span>", self)
176
+ grid.addWidget(title, 0, 0)
177
+ self.said = QLabel(self)
178
+ self.said.setStyleSheet("color:palette(placeholder-text); font-size:10px;")
179
+ self.said.setWordWrap(True)
180
+ self.said.setVisible(False)
181
+ grid.addWidget(self.said, 1, 0)
182
+ self.lines = {key: Sparkline(key, self) for key in ("CPU", "Memory", "GPU")}
183
+ for at, line in enumerate(self.lines.values(), start=2): # stacked: each its own axis, full width
184
+ line.interval = interval
185
+ grid.addWidget(line, at, 0)
186
+ self.lines["GPU"].setVisible(False)
187
+
188
+ def apply(self, sample: Sample) -> None:
189
+ self.said.setText(sample.error)
190
+ self.said.setVisible(bool(sample.error))
191
+ if sample.error:
192
+ return
193
+ self.lines["CPU"].push(sample.cpu)
194
+ self.lines["Memory"].push(sample.mem, f"{sample.mem_used_gb:.1f}/{sample.mem_total_gb:.0f} GB")
195
+ if sample.gpus: # ponytail: the mean over the cards; a line per card when one host has several that differ
196
+ util = sum(g[0] for g in sample.gpus) / len(sample.gpus)
197
+ used, total = sum(g[1] for g in sample.gpus) / 1024, sum(g[2] for g in sample.gpus) / 1024
198
+ self.lines["GPU"].setVisible(True)
199
+ self.lines["GPU"].push(util, f"{len(sample.gpus)}× · {used:.1f}/{total:.0f} GB")
200
+
201
+
202
+ class ResourcesPanel(QScrollArea):
203
+ """The hosts of the executors that are on, under the name of each, read while shown.
204
+
205
+ `hosts()` gives `(group, name, address, interval_s)` for each host to read; by default
206
+ they come from `workspace` (see `workspace_hosts`).
207
+ """
208
+
209
+ def __init__(self, workspace=None, *, hosts=None, parent=None):
210
+ super().__init__(parent)
211
+ self.hosts = hosts or (lambda: workspace_hosts(workspace))
212
+ self._rows: dict[str, _HostRow] = {}
213
+ self._threads: list[threading.Thread] = []
214
+ self._stop = threading.Event()
215
+ self._relay = _Relay(self)
216
+ self._relay.sampled.connect(self._on_sample)
217
+ self.setWidgetResizable(True)
218
+ self.setFrameShape(QFrame.NoFrame)
219
+
220
+ def showEvent(self, event):
221
+ super().showEvent(event)
222
+ self.start()
223
+
224
+ def hideEvent(self, event):
225
+ self.stop()
226
+ super().hideEvent(event)
227
+
228
+ def start(self) -> None:
229
+ self.stop()
230
+ self._stop = stop = threading.Event()
231
+ body = QWidget()
232
+ layout = QVBoxLayout(body)
233
+ layout.setContentsMargins(4, 4, 4, 4)
234
+ self._rows.clear()
235
+ try:
236
+ listed = list(self.hosts())
237
+ except Exception as error: # noqa: BLE001
238
+ listed, failed = [], str(error)
239
+ else:
240
+ failed = ""
241
+ group = None
242
+ for at, (in_group, name, address, interval) in enumerate(listed):
243
+ if in_group != group:
244
+ group = in_group
245
+ heading = QLabel(f"<b>{group}</b>", body)
246
+ heading.setStyleSheet("color:palette(placeholder-text); margin-top:6px;")
247
+ layout.addWidget(heading)
248
+ key = f"{at}:{name}"
249
+ self._rows[key] = row = _HostRow(name, address, interval, body)
250
+ layout.addWidget(row)
251
+ thread = threading.Thread(target=self._read, args=(key, address, interval, stop),
252
+ name=f"resources-{name}", daemon=True)
253
+ self._threads.append(thread)
254
+ thread.start()
255
+ if not listed:
256
+ empty = QLabel(failed or "No executor is on.", body)
257
+ empty.setStyleSheet("color:palette(placeholder-text);")
258
+ layout.addWidget(empty)
259
+ layout.addStretch(1)
260
+ self.setWidget(body)
261
+
262
+ def stop(self) -> None:
263
+ self._stop.set()
264
+ self._threads.clear() # daemons: each ends at its next wake-up, an ssh call in flight included
265
+
266
+ def _read(self, key: str, address: str, interval: int, stop: threading.Event) -> None:
267
+ reader = _Reader(address)
268
+ while not stop.is_set():
269
+ began = time.monotonic()
270
+ sample = reader.read()
271
+ if stop.is_set():
272
+ return
273
+ self._relay.sampled.emit(key, sample)
274
+ stop.wait(max(0.5, interval - (time.monotonic() - began)))
275
+
276
+ def _on_sample(self, key: str, sample: Sample) -> None:
277
+ row = self._rows.get(key)
278
+ if row is not None:
279
+ row.apply(sample)
280
+
281
+
282
+ def workspace_hosts(workspace) -> list[tuple[str, str, str, int]]:
283
+ """`(executor, host, address, interval)` for every host of an executor that is on, a host under
284
+ the first one that has it; this machine always, under `local` when no executor that is on has it."""
285
+ if workspace is None:
286
+ return [("local", "local", "", INTERVALS["local"])]
287
+ kept = workspace.settings_manager.settings.hosts
288
+ rows, seen = [], set()
289
+ for executor in workspace.executors():
290
+ if executor["state"] != "on":
291
+ continue
292
+ for name in executor["hosts"]:
293
+ if name in seen:
294
+ continue
295
+ seen.add(name)
296
+ address = getattr(kept.get(name), "address", "") or ""
297
+ kind = "local" if not address else executor["kind"]
298
+ group = f"{executor['name']} ({executor['kind']}{', login node' if kind == 'queue' else ''})"
299
+ rows.append((group, name, address, INTERVALS.get(kind, INTERVALS["ray"])))
300
+ if "local" not in seen:
301
+ rows.insert(0, ("local", "local", "", INTERVALS["local"]))
302
+ return rows
303
+
304
+
305
+ __all__ = ["INTERVALS", "ResourcesPanel", "Sample", "Sparkline", "parse", "workspace_hosts"]