hpc-runner 0.1.1__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hpc_runner/_version.py +2 -2
- hpc_runner/cli/cancel.py +1 -1
- hpc_runner/cli/config.py +2 -2
- hpc_runner/cli/main.py +17 -13
- hpc_runner/cli/monitor.py +30 -0
- hpc_runner/cli/run.py +223 -67
- hpc_runner/cli/status.py +6 -5
- hpc_runner/core/__init__.py +30 -0
- hpc_runner/core/descriptors.py +87 -33
- hpc_runner/core/exceptions.py +9 -0
- hpc_runner/core/job.py +272 -93
- hpc_runner/core/job_info.py +104 -0
- hpc_runner/core/result.py +4 -0
- hpc_runner/schedulers/base.py +148 -30
- hpc_runner/schedulers/detection.py +22 -4
- hpc_runner/schedulers/local/scheduler.py +119 -2
- hpc_runner/schedulers/sge/args.py +161 -94
- hpc_runner/schedulers/sge/parser.py +106 -13
- hpc_runner/schedulers/sge/scheduler.py +727 -171
- hpc_runner/schedulers/sge/templates/batch.sh.j2 +82 -0
- hpc_runner/schedulers/sge/templates/interactive.sh.j2 +78 -0
- hpc_runner/tui/__init__.py +5 -0
- hpc_runner/tui/app.py +436 -0
- hpc_runner/tui/components/__init__.py +17 -0
- hpc_runner/tui/components/detail_panel.py +187 -0
- hpc_runner/tui/components/filter_bar.py +174 -0
- hpc_runner/tui/components/filter_popup.py +345 -0
- hpc_runner/tui/components/job_table.py +260 -0
- hpc_runner/tui/providers/__init__.py +5 -0
- hpc_runner/tui/providers/jobs.py +197 -0
- hpc_runner/tui/screens/__init__.py +7 -0
- hpc_runner/tui/screens/confirm.py +67 -0
- hpc_runner/tui/screens/job_details.py +210 -0
- hpc_runner/tui/screens/log_viewer.py +170 -0
- hpc_runner/tui/snapshot.py +153 -0
- hpc_runner/tui/styles/monitor.tcss +567 -0
- hpc_runner-0.2.1.dist-info/METADATA +285 -0
- hpc_runner-0.2.1.dist-info/RECORD +56 -0
- hpc_runner/schedulers/sge/templates/job.sh.j2 +0 -39
- hpc_runner-0.1.1.dist-info/METADATA +0 -46
- hpc_runner-0.1.1.dist-info/RECORD +0 -38
- {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/WHEEL +0 -0
- {hpc_runner-0.1.1.dist-info → hpc_runner-0.2.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Generated by hpc-tools (SGE scheduler)
|
|
3
|
+
|
|
4
|
+
{% for directive in directives %}
|
|
5
|
+
{{ directive }}
|
|
6
|
+
{% endfor %}
|
|
7
|
+
|
|
8
|
+
# Exit on error
|
|
9
|
+
set -e
|
|
10
|
+
|
|
11
|
+
# Module system initialization
|
|
12
|
+
{% if scheduler.module_init_script %}
|
|
13
|
+
. {{ scheduler.module_init_script }}
|
|
14
|
+
{% else %}
|
|
15
|
+
if [ -f /etc/profile.d/modules.sh ]; then
|
|
16
|
+
. /etc/profile.d/modules.sh
|
|
17
|
+
elif [ -f /usr/share/Modules/init/bash ]; then
|
|
18
|
+
. /usr/share/Modules/init/bash
|
|
19
|
+
fi
|
|
20
|
+
{% endif %}
|
|
21
|
+
|
|
22
|
+
{% if scheduler.purge_modules %}
|
|
23
|
+
# Purge modules for clean environment
|
|
24
|
+
module purge{% if scheduler.silent_modules %} -s{% endif %}
|
|
25
|
+
|
|
26
|
+
{% endif %}
|
|
27
|
+
{% if job.modules_path %}
|
|
28
|
+
# Additional module paths
|
|
29
|
+
{% for path in job.modules_path %}
|
|
30
|
+
module use {{ path }}
|
|
31
|
+
{% endfor %}
|
|
32
|
+
{% endif %}
|
|
33
|
+
|
|
34
|
+
{% if job.modules %}
|
|
35
|
+
# Load modules
|
|
36
|
+
{% for mod in job.modules %}
|
|
37
|
+
module load {{ mod }}{% if scheduler.silent_modules %} -s{% endif %}
|
|
38
|
+
|
|
39
|
+
{% endfor %}
|
|
40
|
+
{% endif %}
|
|
41
|
+
|
|
42
|
+
{% if job.venv %}
|
|
43
|
+
# Activate virtual environment
|
|
44
|
+
source {{ job.venv }}/bin/activate
|
|
45
|
+
{% endif %}
|
|
46
|
+
|
|
47
|
+
{% if scheduler.unset_vars %}
|
|
48
|
+
# Unset environment variables
|
|
49
|
+
{% for var in scheduler.unset_vars %}
|
|
50
|
+
unset {{ var }}
|
|
51
|
+
{% endfor %}
|
|
52
|
+
{% endif %}
|
|
53
|
+
|
|
54
|
+
{% if job.env_vars %}
|
|
55
|
+
# Set custom environment variables
|
|
56
|
+
{% for key, value in job.env_vars.items() %}
|
|
57
|
+
export {{ key }}="{{ value }}"
|
|
58
|
+
{% endfor %}
|
|
59
|
+
{% endif %}
|
|
60
|
+
|
|
61
|
+
{% if scheduler.expand_makeflags %}
|
|
62
|
+
# Expand MAKEFLAGS (e.g., -j$NSLOTS)
|
|
63
|
+
if [ -n "$MAKEFLAGS" ]; then
|
|
64
|
+
export MAKEFLAGS=$(echo "$MAKEFLAGS" | envsubst)
|
|
65
|
+
fi
|
|
66
|
+
{% endif %}
|
|
67
|
+
|
|
68
|
+
{% if job.workdir %}
|
|
69
|
+
# Change to working directory
|
|
70
|
+
cd {{ job.workdir }}
|
|
71
|
+
{% endif %}
|
|
72
|
+
|
|
73
|
+
# Execute command
|
|
74
|
+
{{ job.command }}
|
|
75
|
+
_exit_code=$?
|
|
76
|
+
|
|
77
|
+
{% if script_path and not keep_script %}
|
|
78
|
+
# Cleanup temporary script
|
|
79
|
+
rm -f {{ script_path }}
|
|
80
|
+
{% endif %}
|
|
81
|
+
|
|
82
|
+
exit $_exit_code
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Generated by hpc-tools (SGE interactive job)
|
|
3
|
+
|
|
4
|
+
# Exit on error
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
# Module system initialization
|
|
8
|
+
{% if scheduler.module_init_script %}
|
|
9
|
+
. {{ scheduler.module_init_script }}
|
|
10
|
+
{% else %}
|
|
11
|
+
if [ -f /etc/profile.d/modules.sh ]; then
|
|
12
|
+
. /etc/profile.d/modules.sh
|
|
13
|
+
elif [ -f /usr/share/Modules/init/bash ]; then
|
|
14
|
+
. /usr/share/Modules/init/bash
|
|
15
|
+
fi
|
|
16
|
+
{% endif %}
|
|
17
|
+
|
|
18
|
+
{% if scheduler.purge_modules %}
|
|
19
|
+
# Purge modules for clean environment
|
|
20
|
+
module purge{% if scheduler.silent_modules %} -s{% endif %}
|
|
21
|
+
|
|
22
|
+
{% endif %}
|
|
23
|
+
{% if job.modules_path %}
|
|
24
|
+
# Additional module paths
|
|
25
|
+
{% for path in job.modules_path %}
|
|
26
|
+
module use {{ path }}
|
|
27
|
+
{% endfor %}
|
|
28
|
+
{% endif %}
|
|
29
|
+
|
|
30
|
+
{% if job.modules %}
|
|
31
|
+
# Load modules
|
|
32
|
+
{% for mod in job.modules %}
|
|
33
|
+
module load {{ mod }}{% if scheduler.silent_modules %} -s{% endif %}
|
|
34
|
+
|
|
35
|
+
{% endfor %}
|
|
36
|
+
{% endif %}
|
|
37
|
+
|
|
38
|
+
{% if job.venv %}
|
|
39
|
+
# Activate virtual environment
|
|
40
|
+
source {{ job.venv }}/bin/activate
|
|
41
|
+
{% endif %}
|
|
42
|
+
|
|
43
|
+
{% if scheduler.unset_vars %}
|
|
44
|
+
# Unset environment variables
|
|
45
|
+
{% for var in scheduler.unset_vars %}
|
|
46
|
+
unset {{ var }}
|
|
47
|
+
{% endfor %}
|
|
48
|
+
{% endif %}
|
|
49
|
+
|
|
50
|
+
{% if job.env_vars %}
|
|
51
|
+
# Set custom environment variables
|
|
52
|
+
{% for key, value in job.env_vars.items() %}
|
|
53
|
+
export {{ key }}="{{ value }}"
|
|
54
|
+
{% endfor %}
|
|
55
|
+
{% endif %}
|
|
56
|
+
|
|
57
|
+
{% if scheduler.expand_makeflags %}
|
|
58
|
+
# Expand MAKEFLAGS (e.g., -j$NSLOTS)
|
|
59
|
+
if [ -n "$MAKEFLAGS" ]; then
|
|
60
|
+
export MAKEFLAGS=$(echo "$MAKEFLAGS" | envsubst)
|
|
61
|
+
fi
|
|
62
|
+
{% endif %}
|
|
63
|
+
|
|
64
|
+
{% if job.workdir %}
|
|
65
|
+
# Change to working directory
|
|
66
|
+
cd {{ job.workdir }}
|
|
67
|
+
{% endif %}
|
|
68
|
+
|
|
69
|
+
# Execute command
|
|
70
|
+
{{ job.command }}
|
|
71
|
+
_exit_code=$?
|
|
72
|
+
|
|
73
|
+
{% if not keep_script %}
|
|
74
|
+
# Cleanup temporary script
|
|
75
|
+
rm -f {{ script_path }}
|
|
76
|
+
{% endif %}
|
|
77
|
+
|
|
78
|
+
exit $_exit_code
|
hpc_runner/tui/app.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Main HPC Monitor TUI application.
|
|
2
|
+
|
|
3
|
+
Uses modern Textual patterns:
|
|
4
|
+
- Reactive attributes for automatic UI updates
|
|
5
|
+
- run_worker for async scheduler calls
|
|
6
|
+
- set_interval for auto-refresh
|
|
7
|
+
- Message-based event handling
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import HorizontalGroup, Vertical
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from textual.theme import Theme
|
|
20
|
+
from textual.widgets import DataTable, Header, Static, TabbedContent, TabPane
|
|
21
|
+
|
|
22
|
+
from hpc_runner.core.job_info import JobInfo
|
|
23
|
+
from hpc_runner.schedulers import get_scheduler
|
|
24
|
+
from hpc_runner.tui.components import (
|
|
25
|
+
DetailPanel,
|
|
26
|
+
FilterPanel,
|
|
27
|
+
FilterStatusLine,
|
|
28
|
+
HelpPopup,
|
|
29
|
+
JobTable,
|
|
30
|
+
)
|
|
31
|
+
from hpc_runner.tui.providers import JobProvider
|
|
32
|
+
from hpc_runner.tui.screens import ConfirmScreen, JobDetailsScreen, LogViewerScreen
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Custom theme inspired by Nord color palette for a muted, professional look.
|
|
36
|
+
# NOTE: We intentionally do NOT set 'background' or 'foreground' here.
|
|
37
|
+
# This allows the terminal's own colors to show through (transparency).
|
|
38
|
+
# The theme only defines accent colors used for highlights and status.
|
|
39
|
+
HPC_MONITOR_THEME = Theme(
|
|
40
|
+
name="hpc-monitor",
|
|
41
|
+
primary="#88C0D0", # Muted teal (not bright blue)
|
|
42
|
+
secondary="#81A1C1", # Lighter blue-gray
|
|
43
|
+
accent="#B48EAD", # Muted purple
|
|
44
|
+
success="#A3BE8C", # Muted green
|
|
45
|
+
warning="#EBCB8B", # Muted yellow
|
|
46
|
+
error="#BF616A", # Muted red
|
|
47
|
+
surface="#3B4252", # For elevated surfaces
|
|
48
|
+
panel="#434C5E", # Panel accents
|
|
49
|
+
dark=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HpcMonitorApp(App[None]):
|
|
54
|
+
"""Textual app for monitoring HPC jobs.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
refresh_interval: Seconds between auto-refresh of job data.
|
|
58
|
+
user_filter: Filter jobs by "me" (current user) or "all" users.
|
|
59
|
+
auto_refresh_enabled: Whether auto-refresh is active.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
TITLE = "hpc monitor"
|
|
63
|
+
|
|
64
|
+
CSS_PATH: ClassVar[Path] = Path(__file__).parent / "styles" / "monitor.tcss"
|
|
65
|
+
|
|
66
|
+
BINDINGS = [
|
|
67
|
+
Binding("q", "quit", "Quit"),
|
|
68
|
+
Binding("r", "refresh", "Refresh"),
|
|
69
|
+
Binding("u", "toggle_user", "Toggle User"),
|
|
70
|
+
Binding("/", "filter_search", "Search", show=False),
|
|
71
|
+
Binding("s", "screenshot", "Screenshot", show=False),
|
|
72
|
+
Binding("question_mark", "help", "Help", show=False),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Reactive attributes - changes automatically trigger watch methods
|
|
76
|
+
user_filter: reactive[str] = reactive("me")
|
|
77
|
+
auto_refresh_enabled: reactive[bool] = reactive(True)
|
|
78
|
+
|
|
79
|
+
def __init__(self, refresh_interval: int = 10) -> None:
|
|
80
|
+
"""Initialize the HPC Monitor app.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
refresh_interval: Seconds between auto-refresh cycles.
|
|
84
|
+
"""
|
|
85
|
+
super().__init__()
|
|
86
|
+
self._refresh_interval = refresh_interval
|
|
87
|
+
self._user = os.environ.get("USER", "unknown")
|
|
88
|
+
self._hostname = socket.gethostname().split(".")[0] # Short hostname
|
|
89
|
+
|
|
90
|
+
# Initialize scheduler and job provider
|
|
91
|
+
self._scheduler = get_scheduler()
|
|
92
|
+
self._job_provider = JobProvider(self._scheduler)
|
|
93
|
+
|
|
94
|
+
# Store full job list for client-side filtering
|
|
95
|
+
self._all_jobs: list[JobInfo] = []
|
|
96
|
+
self._status_filter: str | None = None
|
|
97
|
+
self._queue_filter: str | None = None
|
|
98
|
+
self._search_filter: str = ""
|
|
99
|
+
|
|
100
|
+
# Store extra details for currently selected job (for Enter popup)
|
|
101
|
+
self._selected_job_extra: dict[str, object] = {}
|
|
102
|
+
|
|
103
|
+
def compose(self) -> ComposeResult:
|
|
104
|
+
"""Create child widgets for the app."""
|
|
105
|
+
yield Header()
|
|
106
|
+
with TabbedContent(id="tabs"):
|
|
107
|
+
with TabPane("Active", id="active-tab"):
|
|
108
|
+
with Vertical(id="active-content"):
|
|
109
|
+
yield FilterStatusLine()
|
|
110
|
+
yield JobTable(id="active-jobs")
|
|
111
|
+
yield DetailPanel(id="detail-panel")
|
|
112
|
+
with TabPane("Completed", id="completed-tab"):
|
|
113
|
+
yield Static(
|
|
114
|
+
"Completed jobs will appear here", id="completed-placeholder"
|
|
115
|
+
)
|
|
116
|
+
# Custom footer for ANSI transparency (Textual's Footer doesn't respect it)
|
|
117
|
+
with HorizontalGroup(id="footer"):
|
|
118
|
+
yield Static(" q", classes="footer-key")
|
|
119
|
+
yield Static("Quit", classes="footer-label")
|
|
120
|
+
yield Static(" r", classes="footer-key")
|
|
121
|
+
yield Static("Refresh", classes="footer-label")
|
|
122
|
+
yield Static(" u", classes="footer-key")
|
|
123
|
+
yield Static("User", classes="footer-label")
|
|
124
|
+
yield Static(" ↵", classes="footer-key")
|
|
125
|
+
yield Static("Details", classes="footer-label")
|
|
126
|
+
yield Static(" /", classes="footer-key")
|
|
127
|
+
yield Static("Search", classes="footer-label")
|
|
128
|
+
yield Static(" ?", classes="footer-key")
|
|
129
|
+
yield Static("Help", classes="footer-label")
|
|
130
|
+
|
|
131
|
+
def on_mount(self) -> None:
|
|
132
|
+
"""Called when app is mounted - set up timers and initial data fetch."""
|
|
133
|
+
# Register and apply custom theme for muted, professional aesthetic
|
|
134
|
+
self.register_theme(HPC_MONITOR_THEME)
|
|
135
|
+
self.theme = "hpc-monitor"
|
|
136
|
+
|
|
137
|
+
# Enable ANSI color mode for transparent backgrounds
|
|
138
|
+
# This allows the terminal's own background to show through
|
|
139
|
+
self.ansi_color = True
|
|
140
|
+
|
|
141
|
+
# Update header subtitle with user@hostname and scheduler info
|
|
142
|
+
self.sub_title = f"{self._user}@{self._hostname} ({self._scheduler.name})"
|
|
143
|
+
|
|
144
|
+
# Set up auto-refresh timer
|
|
145
|
+
self._refresh_timer = self.set_interval(
|
|
146
|
+
self._refresh_interval,
|
|
147
|
+
self._on_refresh_timer,
|
|
148
|
+
pause=False, # Start immediately
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Mount help popup
|
|
152
|
+
self._help_popup = HelpPopup(id="help-popup")
|
|
153
|
+
self.mount(self._help_popup)
|
|
154
|
+
|
|
155
|
+
# Fetch initial data
|
|
156
|
+
self._refresh_active_jobs()
|
|
157
|
+
|
|
158
|
+
# Focus the job table by default
|
|
159
|
+
self.query_one("#active-jobs", JobTable).focus()
|
|
160
|
+
|
|
161
|
+
def _on_refresh_timer(self) -> None:
|
|
162
|
+
"""Called by the refresh timer - triggers data fetch."""
|
|
163
|
+
if self.auto_refresh_enabled:
|
|
164
|
+
self._refresh_active_jobs()
|
|
165
|
+
|
|
166
|
+
async def action_quit(self) -> None:
|
|
167
|
+
"""Quit the application."""
|
|
168
|
+
self.exit()
|
|
169
|
+
|
|
170
|
+
def action_screenshot(self) -> None:
|
|
171
|
+
"""Save a screenshot to the current directory."""
|
|
172
|
+
path = self.save_screenshot(path="./")
|
|
173
|
+
self.notify(f"Screenshot saved: {path}", timeout=3)
|
|
174
|
+
|
|
175
|
+
def action_help(self) -> None:
|
|
176
|
+
"""Show help popup."""
|
|
177
|
+
self._help_popup.show_popup()
|
|
178
|
+
|
|
179
|
+
def action_refresh(self) -> None:
|
|
180
|
+
"""Manually trigger a data refresh."""
|
|
181
|
+
self._refresh_active_jobs()
|
|
182
|
+
|
|
183
|
+
def action_view_details(self) -> None:
|
|
184
|
+
"""Open full details popup for selected job."""
|
|
185
|
+
try:
|
|
186
|
+
detail_panel = self.query_one("#detail-panel", DetailPanel)
|
|
187
|
+
job = detail_panel._job
|
|
188
|
+
if job is None:
|
|
189
|
+
self.notify("No job selected", severity="warning", timeout=2)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
def refocus_table(_: None) -> None:
|
|
193
|
+
"""Restore focus to job table after modal closes."""
|
|
194
|
+
self.query_one("#active-jobs", JobTable).focus()
|
|
195
|
+
|
|
196
|
+
self.push_screen(
|
|
197
|
+
JobDetailsScreen(job=job, extra_details=self._selected_job_extra),
|
|
198
|
+
refocus_table,
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
self.notify(f"Error: {e}", severity="error", timeout=3)
|
|
202
|
+
|
|
203
|
+
def _refresh_active_jobs(self) -> None:
|
|
204
|
+
"""Fetch active jobs and update the table.
|
|
205
|
+
|
|
206
|
+
Uses run_worker to run as a background task without blocking UI.
|
|
207
|
+
The exclusive=True ensures only one refresh runs at a time.
|
|
208
|
+
"""
|
|
209
|
+
self.run_worker(self._fetch_and_update_jobs, exclusive=True)
|
|
210
|
+
|
|
211
|
+
async def _fetch_and_update_jobs(self) -> None:
|
|
212
|
+
"""Async coroutine to fetch jobs and update the table."""
|
|
213
|
+
try:
|
|
214
|
+
jobs = await self._job_provider.get_active_jobs(
|
|
215
|
+
user_filter=self.user_filter,
|
|
216
|
+
)
|
|
217
|
+
# Store all jobs for client-side filtering
|
|
218
|
+
self._all_jobs = jobs
|
|
219
|
+
|
|
220
|
+
# Update queue filter with available queues
|
|
221
|
+
queues = sorted(set(j.queue for j in jobs if j.queue))
|
|
222
|
+
try:
|
|
223
|
+
status_line = self.query_one(FilterStatusLine)
|
|
224
|
+
status_line.update_queues(queues)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
# Apply filters and update display
|
|
229
|
+
self._apply_filters_and_display()
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
self.notify(f"Error: {e}", severity="error", timeout=3)
|
|
233
|
+
|
|
234
|
+
def _apply_filters_and_display(self) -> None:
|
|
235
|
+
"""Apply current filters and update the job table."""
|
|
236
|
+
filtered = self._all_jobs
|
|
237
|
+
|
|
238
|
+
# Filter by status
|
|
239
|
+
if self._status_filter:
|
|
240
|
+
filtered = [
|
|
241
|
+
j for j in filtered
|
|
242
|
+
if j.status.name.lower() == self._status_filter.lower()
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
# Filter by queue
|
|
246
|
+
if self._queue_filter:
|
|
247
|
+
filtered = [j for j in filtered if j.queue == self._queue_filter]
|
|
248
|
+
|
|
249
|
+
# Filter by search (name or ID)
|
|
250
|
+
if self._search_filter:
|
|
251
|
+
search = self._search_filter.lower()
|
|
252
|
+
filtered = [
|
|
253
|
+
j for j in filtered
|
|
254
|
+
if search in j.name.lower() or search in j.job_id.lower()
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# Update table
|
|
258
|
+
table = self.query_one("#active-jobs", JobTable)
|
|
259
|
+
table.update_jobs(filtered)
|
|
260
|
+
|
|
261
|
+
# Clear detail panel if filtered list is empty or selected job no longer in list
|
|
262
|
+
try:
|
|
263
|
+
detail_panel = self.query_one("#detail-panel", DetailPanel)
|
|
264
|
+
if not filtered:
|
|
265
|
+
# No jobs after filtering - clear detail panel
|
|
266
|
+
detail_panel.update_job(None)
|
|
267
|
+
elif detail_panel._job is not None:
|
|
268
|
+
# Check if currently displayed job is still in filtered list
|
|
269
|
+
current_job_id = detail_panel._job.job_id
|
|
270
|
+
if not any(j.job_id == current_job_id for j in filtered):
|
|
271
|
+
detail_panel.update_job(None)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
# Update subtitle with counts
|
|
276
|
+
total = len(self._all_jobs)
|
|
277
|
+
shown = len(filtered)
|
|
278
|
+
filter_text = "my" if self.user_filter == "me" else "all"
|
|
279
|
+
if shown == total:
|
|
280
|
+
self.sub_title = (
|
|
281
|
+
f"{self._user}@{self._hostname} ({self._scheduler.name}) "
|
|
282
|
+
f"· {total} {filter_text} job{'s' if total != 1 else ''}"
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
self.sub_title = (
|
|
286
|
+
f"{self._user}@{self._hostname} ({self._scheduler.name}) "
|
|
287
|
+
f"· {shown}/{total} {filter_text} jobs"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def action_toggle_user(self) -> None:
|
|
291
|
+
"""Toggle between showing current user's jobs and all jobs."""
|
|
292
|
+
self.user_filter = "all" if self.user_filter == "me" else "me"
|
|
293
|
+
|
|
294
|
+
def watch_user_filter(self, old_value: str, new_value: str) -> None:
|
|
295
|
+
"""React to user filter changes."""
|
|
296
|
+
self.notify(f"Filter: {new_value}", timeout=1)
|
|
297
|
+
# Trigger refresh with new filter
|
|
298
|
+
self._refresh_active_jobs()
|
|
299
|
+
|
|
300
|
+
def action_filter_search(self) -> None:
|
|
301
|
+
"""Focus the search input."""
|
|
302
|
+
self.query_one(FilterStatusLine).focus_search()
|
|
303
|
+
|
|
304
|
+
def on_filter_panel_filter_changed(
|
|
305
|
+
self, event: FilterPanel.FilterChanged
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Handle filter panel changes (arrow key navigation)."""
|
|
308
|
+
if event.filter_type == "status":
|
|
309
|
+
self._status_filter = event.value
|
|
310
|
+
elif event.filter_type == "queue":
|
|
311
|
+
self._queue_filter = event.value
|
|
312
|
+
self._apply_filters_and_display()
|
|
313
|
+
|
|
314
|
+
def on_filter_status_line_search_changed(
|
|
315
|
+
self, event: FilterStatusLine.SearchChanged
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Handle inline search changes."""
|
|
318
|
+
self._search_filter = event.value
|
|
319
|
+
self._apply_filters_and_display()
|
|
320
|
+
|
|
321
|
+
def on_job_table_job_selected(self, event: JobTable.JobSelected) -> None:
|
|
322
|
+
"""Handle job selection in the table.
|
|
323
|
+
|
|
324
|
+
Fetches detailed job info (including output paths) when a job is selected.
|
|
325
|
+
"""
|
|
326
|
+
# Start with basic info from the event
|
|
327
|
+
job_info = event.job_info
|
|
328
|
+
self._selected_job_extra = {}
|
|
329
|
+
self._last_detail_error: str | None = None
|
|
330
|
+
|
|
331
|
+
# Try to get detailed info (including stdout/stderr paths)
|
|
332
|
+
try:
|
|
333
|
+
result = self._scheduler.get_job_details(job_info.job_id)
|
|
334
|
+
# Handle tuple return (JobInfo, extra_details)
|
|
335
|
+
if isinstance(result, tuple):
|
|
336
|
+
job_info, self._selected_job_extra = result
|
|
337
|
+
else:
|
|
338
|
+
job_info = result
|
|
339
|
+
except (NotImplementedError, Exception) as exc:
|
|
340
|
+
# Scheduler doesn't support details or call failed - use basic info
|
|
341
|
+
self._last_detail_error = f"{type(exc).__name__}: {exc}"
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
detail_panel = self.query_one("#detail-panel", DetailPanel)
|
|
346
|
+
detail_panel.update_job(job_info)
|
|
347
|
+
if self._last_detail_error:
|
|
348
|
+
self.notify(
|
|
349
|
+
f"Details fallback for job {job_info.job_id}: {self._last_detail_error}",
|
|
350
|
+
severity="warning",
|
|
351
|
+
timeout=5,
|
|
352
|
+
)
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
357
|
+
"""Handle Enter key on job table row - open full details popup."""
|
|
358
|
+
# Get the currently displayed job from the detail panel
|
|
359
|
+
try:
|
|
360
|
+
detail_panel = self.query_one("#detail-panel", DetailPanel)
|
|
361
|
+
job = detail_panel._job
|
|
362
|
+
if job is None:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
def refocus_table(_: None) -> None:
|
|
366
|
+
"""Restore focus to job table after modal closes."""
|
|
367
|
+
self.query_one("#active-jobs", JobTable).focus()
|
|
368
|
+
|
|
369
|
+
self.push_screen(
|
|
370
|
+
JobDetailsScreen(job=job, extra_details=self._selected_job_extra),
|
|
371
|
+
refocus_table,
|
|
372
|
+
)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
def on_detail_panel_view_logs(self, event: DetailPanel.ViewLogs) -> None:
|
|
377
|
+
"""Handle request to view job logs."""
|
|
378
|
+
job = event.job
|
|
379
|
+
stream = event.stream
|
|
380
|
+
path = job.stdout_path if stream == "stdout" else job.stderr_path
|
|
381
|
+
|
|
382
|
+
if path is None:
|
|
383
|
+
self.notify(f"No {stream} path available", severity="warning", timeout=3)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
def refocus_table(_: None) -> None:
|
|
387
|
+
"""Restore focus to job table after modal closes."""
|
|
388
|
+
self.query_one("#active-jobs", JobTable).focus()
|
|
389
|
+
|
|
390
|
+
title = f"{stream}: {job.name}"
|
|
391
|
+
self.push_screen(LogViewerScreen(file_path=path, title=title), refocus_table)
|
|
392
|
+
|
|
393
|
+
def on_detail_panel_cancel_job(self, event: DetailPanel.CancelJob) -> None:
|
|
394
|
+
"""Handle request to cancel a job."""
|
|
395
|
+
job = event.job
|
|
396
|
+
|
|
397
|
+
def handle_confirm(confirmed: bool) -> None:
|
|
398
|
+
"""Handle confirmation result and refocus table."""
|
|
399
|
+
if confirmed:
|
|
400
|
+
self._do_cancel_job(job)
|
|
401
|
+
self.query_one("#active-jobs", JobTable).focus()
|
|
402
|
+
|
|
403
|
+
# Format job details for confirmation dialog
|
|
404
|
+
message = (
|
|
405
|
+
f"[bold]Job ID:[/] {job.job_id}\n"
|
|
406
|
+
f"[bold]Name:[/] {job.name}"
|
|
407
|
+
)
|
|
408
|
+
self.push_screen(
|
|
409
|
+
ConfirmScreen(
|
|
410
|
+
message=message,
|
|
411
|
+
title="Terminate Job",
|
|
412
|
+
confirm_label="Confirm",
|
|
413
|
+
),
|
|
414
|
+
handle_confirm,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _do_cancel_job(self, job: JobInfo) -> None:
|
|
418
|
+
"""Actually cancel the job."""
|
|
419
|
+
self.run_worker(self._cancel_job_worker(job), exclusive=False)
|
|
420
|
+
|
|
421
|
+
async def _cancel_job_worker(self, job: JobInfo) -> None:
|
|
422
|
+
"""Worker to cancel job in background."""
|
|
423
|
+
try:
|
|
424
|
+
# Run cancel in thread pool to avoid blocking
|
|
425
|
+
import asyncio
|
|
426
|
+
loop = asyncio.get_event_loop()
|
|
427
|
+
await loop.run_in_executor(
|
|
428
|
+
None,
|
|
429
|
+
self._scheduler.cancel,
|
|
430
|
+
job.job_id,
|
|
431
|
+
)
|
|
432
|
+
self.notify(f"Job {job.job_id} cancelled", severity="information", timeout=3)
|
|
433
|
+
# Refresh the job list
|
|
434
|
+
self._refresh_active_jobs()
|
|
435
|
+
except Exception as e:
|
|
436
|
+
self.notify(f"Failed to cancel: {e}", severity="error", timeout=5)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""TUI components for HPC Monitor."""
|
|
2
|
+
|
|
3
|
+
from .detail_panel import DetailPanel
|
|
4
|
+
from .filter_popup import (
|
|
5
|
+
FilterPanel,
|
|
6
|
+
FilterStatusLine,
|
|
7
|
+
HelpPopup,
|
|
8
|
+
)
|
|
9
|
+
from .job_table import JobTable
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DetailPanel",
|
|
13
|
+
"FilterPanel",
|
|
14
|
+
"FilterStatusLine",
|
|
15
|
+
"HelpPopup",
|
|
16
|
+
"JobTable",
|
|
17
|
+
]
|