hpc-runner 0.2.0__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.
Files changed (56) hide show
  1. hpc_runner/__init__.py +57 -0
  2. hpc_runner/_version.py +34 -0
  3. hpc_runner/cli/__init__.py +1 -0
  4. hpc_runner/cli/cancel.py +38 -0
  5. hpc_runner/cli/config.py +109 -0
  6. hpc_runner/cli/main.py +76 -0
  7. hpc_runner/cli/monitor.py +30 -0
  8. hpc_runner/cli/run.py +292 -0
  9. hpc_runner/cli/status.py +66 -0
  10. hpc_runner/core/__init__.py +31 -0
  11. hpc_runner/core/config.py +177 -0
  12. hpc_runner/core/descriptors.py +110 -0
  13. hpc_runner/core/exceptions.py +38 -0
  14. hpc_runner/core/job.py +328 -0
  15. hpc_runner/core/job_array.py +58 -0
  16. hpc_runner/core/job_info.py +104 -0
  17. hpc_runner/core/resources.py +49 -0
  18. hpc_runner/core/result.py +161 -0
  19. hpc_runner/core/types.py +13 -0
  20. hpc_runner/py.typed +0 -0
  21. hpc_runner/schedulers/__init__.py +60 -0
  22. hpc_runner/schedulers/base.py +194 -0
  23. hpc_runner/schedulers/detection.py +52 -0
  24. hpc_runner/schedulers/local/__init__.py +5 -0
  25. hpc_runner/schedulers/local/scheduler.py +354 -0
  26. hpc_runner/schedulers/local/templates/job.sh.j2 +28 -0
  27. hpc_runner/schedulers/sge/__init__.py +5 -0
  28. hpc_runner/schedulers/sge/args.py +232 -0
  29. hpc_runner/schedulers/sge/parser.py +287 -0
  30. hpc_runner/schedulers/sge/scheduler.py +881 -0
  31. hpc_runner/schedulers/sge/templates/batch.sh.j2 +82 -0
  32. hpc_runner/schedulers/sge/templates/interactive.sh.j2 +78 -0
  33. hpc_runner/templates/__init__.py +5 -0
  34. hpc_runner/templates/engine.py +55 -0
  35. hpc_runner/tui/__init__.py +5 -0
  36. hpc_runner/tui/app.py +436 -0
  37. hpc_runner/tui/components/__init__.py +17 -0
  38. hpc_runner/tui/components/detail_panel.py +187 -0
  39. hpc_runner/tui/components/filter_bar.py +174 -0
  40. hpc_runner/tui/components/filter_popup.py +345 -0
  41. hpc_runner/tui/components/job_table.py +260 -0
  42. hpc_runner/tui/providers/__init__.py +5 -0
  43. hpc_runner/tui/providers/jobs.py +197 -0
  44. hpc_runner/tui/screens/__init__.py +7 -0
  45. hpc_runner/tui/screens/confirm.py +67 -0
  46. hpc_runner/tui/screens/job_details.py +210 -0
  47. hpc_runner/tui/screens/log_viewer.py +170 -0
  48. hpc_runner/tui/snapshot.py +153 -0
  49. hpc_runner/tui/styles/monitor.tcss +567 -0
  50. hpc_runner/workflow/__init__.py +6 -0
  51. hpc_runner/workflow/dependency.py +20 -0
  52. hpc_runner/workflow/pipeline.py +180 -0
  53. hpc_runner-0.2.0.dist-info/METADATA +285 -0
  54. hpc_runner-0.2.0.dist-info/RECORD +56 -0
  55. hpc_runner-0.2.0.dist-info/WHEEL +4 -0
  56. hpc_runner-0.2.0.dist-info/entry_points.txt +2 -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
@@ -0,0 +1,5 @@
1
+ """Template engine for job script generation."""
2
+
3
+ from hpc_runner.templates.engine import render_template
4
+
5
+ __all__ = ["render_template"]
@@ -0,0 +1,55 @@
1
+ """Jinja2 template engine for job scripts."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import jinja2
7
+
8
+ # Template directories - schedulers and package templates
9
+ _SCHEDULERS_DIR = Path(__file__).parent.parent / "schedulers"
10
+ _PACKAGE_DIR = Path(__file__).parent
11
+
12
+ _env: jinja2.Environment | None = None
13
+
14
+
15
+ def _get_env() -> jinja2.Environment:
16
+ """Get or create the Jinja2 environment."""
17
+ global _env
18
+ if _env is None:
19
+ _env = jinja2.Environment(
20
+ loader=jinja2.FileSystemLoader([str(_SCHEDULERS_DIR), str(_PACKAGE_DIR)]),
21
+ trim_blocks=True,
22
+ lstrip_blocks=True,
23
+ keep_trailing_newline=True,
24
+ )
25
+ return _env
26
+
27
+
28
+ def render_template(name: str, **context: Any) -> str:
29
+ """Render a template.
30
+
31
+ Args:
32
+ name: Template name (e.g., "sge/templates/job.sh.j2")
33
+ **context: Template context variables
34
+
35
+ Returns:
36
+ Rendered template content
37
+ """
38
+ env = _get_env()
39
+ template = env.get_template(name)
40
+ return template.render(**context)
41
+
42
+
43
+ def render_string(template_str: str, **context: Any) -> str:
44
+ """Render a template string.
45
+
46
+ Args:
47
+ template_str: Template content as a string
48
+ **context: Template context variables
49
+
50
+ Returns:
51
+ Rendered content
52
+ """
53
+ env = _get_env()
54
+ template = env.from_string(template_str)
55
+ return template.render(**context)
@@ -0,0 +1,5 @@
1
+ """HPC Monitor TUI - Textual-based terminal UI for monitoring HPC jobs."""
2
+
3
+ from .app import HpcMonitorApp
4
+
5
+ __all__ = ["HpcMonitorApp"]
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
+ ]