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.
- {ms_components-0.4.2 → ms_components-0.4.3}/PKG-INFO +1 -1
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_executors.py +83 -2
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/job_cards.py +58 -30
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/models.py +3 -1
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/polling.py +3 -0
- ms_components-0.4.3/src/ms_components/ms_monitor/resources.py +305 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/widget.py +21 -10
- ms_components-0.4.3/src/ms_components/ms_pymol/command_line.py +136 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/widget.py +9 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/controller.py +21 -3
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/overlay.py +8 -2
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/PKG-INFO +1 -1
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/SOURCES.txt +3 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/scm_file_list.json +3 -0
- ms_components-0.4.3/src/ms_components.egg-info/scm_version.json +8 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_executors_ui.py +3 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_molsuite_monitor_widgets.py +18 -1
- ms_components-0.4.3/tests/test_pymol_command_line.py +48 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_tutorial.py +64 -0
- ms_components-0.4.2/src/ms_components.egg-info/scm_version.json +0 -8
- {ms_components-0.4.2 → ms_components-0.4.3}/.github/workflows/docs.yml +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/.github/workflows/publish.yml +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/.gitignore +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/LICENSE +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/MANIFEST.in +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/README.md +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/examples/tutorial_demo.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/examples/tutorial_demo.toml +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/licenses/qt-themes-LICENSE.txt +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/pyproject.toml +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/setup.cfg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/backdrop.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/base.qss +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/chips.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ketcher.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/listview.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_dockwidget/widget.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/bridge.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/cards.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/config.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/detail_panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/formatting.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_monitor/palette.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/app.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/backend.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/browser.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/scratch.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_projects/widget.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/box.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/center.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/display.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/hydrogens.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/style.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/plugins.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_pymol/scene.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_settings/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_settings/panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_stepper.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/action_header.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/cell_delegate.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/column_panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/example_usage.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/filter_panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/columns.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/export.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/filter.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/menu.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/reload.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/search.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/settings.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/icons/sort.svg +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/pagination_control.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/query_builder.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/session_provider.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/smart_table_view.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/sort_panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_config.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_interaction.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/table_model.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/test_external_filters.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_table/test_toolbar.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/__init__.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/bindings.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/ms_tutorial/models.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/opengl.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/pagination.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/palettes.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/step_dialog.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/theme.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/tool_panel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components/wheel.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/dependency_links.txt +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/requires.txt +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/src/ms_components.egg-info/top_level.txt +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/conftest.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_app_settings.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_chips.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_dock_window.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_ketcher.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_molsuite_monitor_bridge.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_monitor_config.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_ms_projects_widget.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_opengl.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_progress_column.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_project_browser.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_grid.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_scene.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_pymol_toolbar.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_query_builder_keyset.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_shift_hscroll.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_split_button_style.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_step_dialog.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_interaction.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_toolbar.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_table_view_state.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_theme_contrast.py +0 -0
- {ms_components-0.4.2 → ms_components-0.4.3}/tests/test_tool_panel.py +0 -0
- {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.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
339
|
-
self.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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.
|
|
373
|
-
parts.append(f"<span style='color:{P['accent']}'>{job.
|
|
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>
|
|
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"]
|