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,210 @@
|
|
|
1
|
+
"""Job details modal screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Static, TextArea
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from hpc_runner.core.job_info import JobInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobDetailsScreen(ModalScreen[None]):
|
|
18
|
+
"""Modal screen for viewing full job details.
|
|
19
|
+
|
|
20
|
+
Displays comprehensive job information including all resource requests,
|
|
21
|
+
paths, dependencies, and other metadata.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
Binding("escape", "close", "Close"),
|
|
26
|
+
Binding("q", "close", "Close"),
|
|
27
|
+
Binding("g", "go_top", "Top", show=False),
|
|
28
|
+
Binding("G", "go_bottom", "Bottom", show=False),
|
|
29
|
+
Binding("s", "screenshot", "Screenshot", show=False),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
job: JobInfo,
|
|
35
|
+
extra_details: dict[str, Any] | None = None,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the job details screen.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
job: The JobInfo object to display.
|
|
42
|
+
extra_details: Additional details from qstat -j (resources, etc.)
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
self._job = job
|
|
46
|
+
self._extra = extra_details or {}
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
"""Create the modal content."""
|
|
50
|
+
with Vertical(id="job-details-dialog"):
|
|
51
|
+
yield TextArea(id="job-details-content", read_only=True)
|
|
52
|
+
yield Static("q/Esc close | g top | G bottom", id="job-details-hint")
|
|
53
|
+
|
|
54
|
+
def on_mount(self) -> None:
|
|
55
|
+
"""Set up the dialog with job content."""
|
|
56
|
+
dialog = self.query_one("#job-details-dialog", Vertical)
|
|
57
|
+
# Keep title short to avoid truncation
|
|
58
|
+
dialog.border_title = f"Job: {self._job.job_id}"
|
|
59
|
+
|
|
60
|
+
# Build and display content
|
|
61
|
+
content = self._build_content()
|
|
62
|
+
text_area = self.query_one("#job-details-content", TextArea)
|
|
63
|
+
text_area.load_text(content)
|
|
64
|
+
text_area.focus()
|
|
65
|
+
|
|
66
|
+
def _build_content(self) -> str:
|
|
67
|
+
"""Build the formatted job details content."""
|
|
68
|
+
job = self._job
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
resources = self._extra.get("resources", {})
|
|
71
|
+
|
|
72
|
+
# Section: Basic Info
|
|
73
|
+
lines.append("═══ Basic Information ═══")
|
|
74
|
+
lines.append("")
|
|
75
|
+
lines.append(f" Job ID: {job.job_id}")
|
|
76
|
+
lines.append(f" Name: {job.name}")
|
|
77
|
+
lines.append(f" User: {job.user}")
|
|
78
|
+
lines.append(f" Status: {job.status.name}")
|
|
79
|
+
lines.append(f" Queue: {job.queue or '—'}")
|
|
80
|
+
lines.append(f" Node: {job.node or '—'}")
|
|
81
|
+
lines.append("")
|
|
82
|
+
|
|
83
|
+
# Section: Command
|
|
84
|
+
job_args = self._extra.get("job_args", [])
|
|
85
|
+
script = self._extra.get("script_file")
|
|
86
|
+
command = self._extra.get("command") # For qrsh interactive jobs
|
|
87
|
+
if job_args or script or command:
|
|
88
|
+
lines.append("═══ Command ═══")
|
|
89
|
+
lines.append("")
|
|
90
|
+
if command:
|
|
91
|
+
# Interactive job command (from QRSH_COMMAND)
|
|
92
|
+
lines.append(f" Command: {command}")
|
|
93
|
+
elif script:
|
|
94
|
+
lines.append(f" Script: {script}")
|
|
95
|
+
if job_args:
|
|
96
|
+
lines.append(f" Arguments: {' '.join(job_args)}")
|
|
97
|
+
lines.append("")
|
|
98
|
+
|
|
99
|
+
# Section: Timing
|
|
100
|
+
lines.append("═══ Timing ═══")
|
|
101
|
+
lines.append("")
|
|
102
|
+
if job.submit_time:
|
|
103
|
+
lines.append(f" Submitted: {job.submit_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
104
|
+
else:
|
|
105
|
+
lines.append(" Submitted: —")
|
|
106
|
+
if job.start_time:
|
|
107
|
+
lines.append(f" Started: {job.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
108
|
+
else:
|
|
109
|
+
lines.append(" Started: —")
|
|
110
|
+
if job.end_time:
|
|
111
|
+
lines.append(f" Ended: {job.end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
112
|
+
lines.append(f" Runtime: {job.runtime_display}")
|
|
113
|
+
lines.append("")
|
|
114
|
+
|
|
115
|
+
# Section: Resources
|
|
116
|
+
lines.append("═══ Resources ═══")
|
|
117
|
+
lines.append("")
|
|
118
|
+
|
|
119
|
+
# Slots from PE
|
|
120
|
+
pe_name = self._extra.get("pe_name")
|
|
121
|
+
pe_range = self._extra.get("pe_range")
|
|
122
|
+
if pe_name:
|
|
123
|
+
lines.append(f" PE: {pe_name} ({pe_range or job.cpu or '?'} slots)")
|
|
124
|
+
else:
|
|
125
|
+
lines.append(f" Slots/CPUs: {job.cpu or '—'}")
|
|
126
|
+
|
|
127
|
+
# Memory - check resources dict for common memory keys
|
|
128
|
+
memory = job.memory
|
|
129
|
+
if not memory:
|
|
130
|
+
for key in ("h_vmem", "mem_free", "virtual_free", "mem", "memory"):
|
|
131
|
+
if key in resources:
|
|
132
|
+
memory = resources[key]
|
|
133
|
+
break
|
|
134
|
+
if memory:
|
|
135
|
+
lines.append(f" Memory: {memory}")
|
|
136
|
+
|
|
137
|
+
# GPU
|
|
138
|
+
if job.gpu:
|
|
139
|
+
lines.append(f" GPUs: {job.gpu}")
|
|
140
|
+
|
|
141
|
+
# All requested resources
|
|
142
|
+
if resources:
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append(" All Requested Resources:")
|
|
145
|
+
for name, value in sorted(resources.items()):
|
|
146
|
+
lines.append(f" {name}: {value}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
|
|
149
|
+
# Section: Paths
|
|
150
|
+
lines.append("═══ Paths ═══")
|
|
151
|
+
lines.append("")
|
|
152
|
+
cwd = self._extra.get("cwd")
|
|
153
|
+
if cwd:
|
|
154
|
+
lines.append(f" Working Dir: {cwd}")
|
|
155
|
+
lines.append(f" Stdout: {job.stdout_path or '—'}")
|
|
156
|
+
lines.append(f" Stderr: {job.stderr_path or '—'}")
|
|
157
|
+
lines.append("")
|
|
158
|
+
|
|
159
|
+
# Section: Dependencies
|
|
160
|
+
deps = self._extra.get("dependencies", [])
|
|
161
|
+
if deps or job.dependencies:
|
|
162
|
+
lines.append("═══ Dependencies ═══")
|
|
163
|
+
lines.append("")
|
|
164
|
+
all_deps = deps or job.dependencies or []
|
|
165
|
+
if all_deps:
|
|
166
|
+
for dep in all_deps:
|
|
167
|
+
lines.append(f" • {dep}")
|
|
168
|
+
else:
|
|
169
|
+
lines.append(" None")
|
|
170
|
+
lines.append("")
|
|
171
|
+
|
|
172
|
+
# Section: Array Job Info
|
|
173
|
+
if job.array_task_id is not None:
|
|
174
|
+
lines.append("═══ Array Job ═══")
|
|
175
|
+
lines.append("")
|
|
176
|
+
lines.append(f" Task ID: {job.array_task_id}")
|
|
177
|
+
lines.append("")
|
|
178
|
+
|
|
179
|
+
# Section: Other
|
|
180
|
+
project = self._extra.get("project")
|
|
181
|
+
department = self._extra.get("department")
|
|
182
|
+
if project or department:
|
|
183
|
+
lines.append("═══ Other ═══")
|
|
184
|
+
lines.append("")
|
|
185
|
+
if project:
|
|
186
|
+
lines.append(f" Project: {project}")
|
|
187
|
+
if department:
|
|
188
|
+
lines.append(f" Department: {department}")
|
|
189
|
+
lines.append("")
|
|
190
|
+
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
def action_close(self) -> None:
|
|
194
|
+
"""Close the details viewer."""
|
|
195
|
+
self.dismiss(None)
|
|
196
|
+
|
|
197
|
+
def action_go_top(self) -> None:
|
|
198
|
+
"""Scroll to top."""
|
|
199
|
+
text_area = self.query_one("#job-details-content", TextArea)
|
|
200
|
+
text_area.cursor_location = (0, 0)
|
|
201
|
+
|
|
202
|
+
def action_go_bottom(self) -> None:
|
|
203
|
+
"""Scroll to bottom."""
|
|
204
|
+
text_area = self.query_one("#job-details-content", TextArea)
|
|
205
|
+
text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
|
|
206
|
+
|
|
207
|
+
def action_screenshot(self) -> None:
|
|
208
|
+
"""Save a screenshot."""
|
|
209
|
+
path = self.app.save_screenshot(path="./")
|
|
210
|
+
self.app.notify(f"Screenshot saved: {path}", timeout=3)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Log viewer modal screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Static, TextArea
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Maximum lines to read from large files
|
|
16
|
+
MAX_LINES = 5000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LogViewerScreen(ModalScreen[None]):
|
|
20
|
+
"""Modal screen for viewing job log files.
|
|
21
|
+
|
|
22
|
+
Displays file content in a scrollable text area.
|
|
23
|
+
Styles are defined in monitor.tcss.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
Binding("escape", "close", "Close"),
|
|
28
|
+
Binding("q", "close", "Close"),
|
|
29
|
+
Binding("g", "go_top", "Top", show=False),
|
|
30
|
+
Binding("G", "go_bottom", "Bottom", show=False),
|
|
31
|
+
Binding("s", "screenshot", "Screenshot", show=False),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
file_path: Path | str,
|
|
37
|
+
title: str = "Log Viewer",
|
|
38
|
+
**kwargs: Any,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self._file_path = Path(file_path) if isinstance(file_path, str) else file_path
|
|
42
|
+
self._title = title
|
|
43
|
+
self._content: str = ""
|
|
44
|
+
self._error: str | None = None
|
|
45
|
+
|
|
46
|
+
def compose(self) -> ComposeResult:
|
|
47
|
+
"""Create the modal content."""
|
|
48
|
+
with Vertical(id="log-viewer-dialog"):
|
|
49
|
+
yield Static(str(self._file_path), id="log-viewer-path")
|
|
50
|
+
yield TextArea(id="log-viewer-content", read_only=True)
|
|
51
|
+
yield Static("q/Esc close | g top | G bottom", id="log-viewer-hint")
|
|
52
|
+
|
|
53
|
+
def on_mount(self) -> None:
|
|
54
|
+
"""Set up the dialog and load file content."""
|
|
55
|
+
dialog = self.query_one("#log-viewer-dialog", Vertical)
|
|
56
|
+
dialog.border_title = self._title
|
|
57
|
+
|
|
58
|
+
# Load file content
|
|
59
|
+
self._load_file()
|
|
60
|
+
|
|
61
|
+
# Update text area with content
|
|
62
|
+
text_area = self.query_one("#log-viewer-content", TextArea)
|
|
63
|
+
if self._error:
|
|
64
|
+
text_area.load_text(self._error)
|
|
65
|
+
else:
|
|
66
|
+
text_area.load_text(self._content)
|
|
67
|
+
# Scroll to bottom by default (most recent output)
|
|
68
|
+
text_area.action_cursor_line_end()
|
|
69
|
+
self.call_after_refresh(self._scroll_to_bottom)
|
|
70
|
+
|
|
71
|
+
text_area.focus()
|
|
72
|
+
|
|
73
|
+
def _scroll_to_bottom(self) -> None:
|
|
74
|
+
"""Scroll text area to bottom."""
|
|
75
|
+
text_area = self.query_one("#log-viewer-content", TextArea)
|
|
76
|
+
# Move cursor to end of document
|
|
77
|
+
text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
|
|
78
|
+
|
|
79
|
+
def _load_file(self) -> None:
|
|
80
|
+
"""Load content from the log file."""
|
|
81
|
+
if not self._file_path.exists():
|
|
82
|
+
self._error = f"File not found:\n{self._file_path}"
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Check file size first
|
|
87
|
+
file_size = self._file_path.stat().st_size
|
|
88
|
+
|
|
89
|
+
if file_size == 0:
|
|
90
|
+
self._content = "(empty file)"
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# For large files, read only the last N lines
|
|
94
|
+
if file_size > 1_000_000: # 1MB threshold
|
|
95
|
+
self._content = self._read_tail()
|
|
96
|
+
else:
|
|
97
|
+
self._content = self._file_path.read_text(encoding="utf-8", errors="replace")
|
|
98
|
+
|
|
99
|
+
# Truncate if too many lines
|
|
100
|
+
lines = self._content.splitlines()
|
|
101
|
+
if len(lines) > MAX_LINES:
|
|
102
|
+
self._content = (
|
|
103
|
+
f"[Showing last {MAX_LINES} of {len(lines)} lines]\n\n"
|
|
104
|
+
+ "\n".join(lines[-MAX_LINES:])
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except PermissionError:
|
|
108
|
+
self._error = f"Permission denied:\n{self._file_path}"
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self._error = f"Error reading file:\n{e}"
|
|
111
|
+
|
|
112
|
+
def _read_tail(self) -> str:
|
|
113
|
+
"""Read the last N lines of a large file efficiently."""
|
|
114
|
+
lines: list[str] = []
|
|
115
|
+
try:
|
|
116
|
+
with open(self._file_path, "rb") as f:
|
|
117
|
+
# Seek to end
|
|
118
|
+
f.seek(0, 2)
|
|
119
|
+
file_size = f.tell()
|
|
120
|
+
|
|
121
|
+
# Read chunks from end
|
|
122
|
+
chunk_size = 8192
|
|
123
|
+
position = file_size
|
|
124
|
+
remaining = b""
|
|
125
|
+
|
|
126
|
+
while position > 0 and len(lines) < MAX_LINES:
|
|
127
|
+
read_size = min(chunk_size, position)
|
|
128
|
+
position -= read_size
|
|
129
|
+
f.seek(position)
|
|
130
|
+
chunk = f.read(read_size) + remaining
|
|
131
|
+
|
|
132
|
+
# Split into lines
|
|
133
|
+
chunk_lines = chunk.split(b"\n")
|
|
134
|
+
remaining = chunk_lines[0]
|
|
135
|
+
lines = [
|
|
136
|
+
line.decode("utf-8", errors="replace")
|
|
137
|
+
for line in chunk_lines[1:]
|
|
138
|
+
] + lines
|
|
139
|
+
|
|
140
|
+
# Add any remaining content
|
|
141
|
+
if remaining and len(lines) < MAX_LINES:
|
|
142
|
+
lines.insert(0, remaining.decode("utf-8", errors="replace"))
|
|
143
|
+
|
|
144
|
+
# Trim to MAX_LINES
|
|
145
|
+
if len(lines) > MAX_LINES:
|
|
146
|
+
lines = lines[-MAX_LINES:]
|
|
147
|
+
|
|
148
|
+
return f"[Large file - showing last {len(lines)} lines]\n\n" + "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return f"Error reading file: {e}"
|
|
152
|
+
|
|
153
|
+
def action_close(self) -> None:
|
|
154
|
+
"""Close the log viewer."""
|
|
155
|
+
self.dismiss(None)
|
|
156
|
+
|
|
157
|
+
def action_go_top(self) -> None:
|
|
158
|
+
"""Scroll to top of file."""
|
|
159
|
+
text_area = self.query_one("#log-viewer-content", TextArea)
|
|
160
|
+
text_area.cursor_location = (0, 0)
|
|
161
|
+
|
|
162
|
+
def action_go_bottom(self) -> None:
|
|
163
|
+
"""Scroll to bottom of file."""
|
|
164
|
+
text_area = self.query_one("#log-viewer-content", TextArea)
|
|
165
|
+
text_area.cursor_location = (len(text_area.document.lines) - 1, 0)
|
|
166
|
+
|
|
167
|
+
def action_screenshot(self) -> None:
|
|
168
|
+
"""Save a screenshot."""
|
|
169
|
+
path = self.app.save_screenshot(path="./")
|
|
170
|
+
self.app.notify(f"Screenshot saved: {path}", timeout=3)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""TUI Snapshot utility for visual review after edits.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m hpc_runner.tui.snapshot
|
|
5
|
+
|
|
6
|
+
This captures a screenshot and reports key visual properties to catch
|
|
7
|
+
regressions like solid backgrounds when transparency is expected.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .app import HpcMonitorApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_transparent(color) -> bool:
|
|
18
|
+
"""Check if a color is transparent."""
|
|
19
|
+
if color is None:
|
|
20
|
+
return True
|
|
21
|
+
if color.a == 0:
|
|
22
|
+
return True
|
|
23
|
+
if hasattr(color, "ansi") and color.ansi == -1:
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _color_hex(color) -> str:
|
|
29
|
+
"""Convert color to hex string for display."""
|
|
30
|
+
if color is None:
|
|
31
|
+
return "None"
|
|
32
|
+
if hasattr(color, "ansi") and color.ansi == -1:
|
|
33
|
+
return "ANSI_DEFAULT (transparent)"
|
|
34
|
+
if color.a == 0:
|
|
35
|
+
return f"transparent (a=0)"
|
|
36
|
+
return f"#{color.r:02x}{color.g:02x}{color.b:02x}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def capture_and_review() -> bool:
|
|
40
|
+
"""Capture snapshot and review visual properties.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if all checks pass, False otherwise.
|
|
44
|
+
"""
|
|
45
|
+
from textual.containers import HorizontalGroup
|
|
46
|
+
from textual.widgets import Tab, Header, TabbedContent, Static
|
|
47
|
+
|
|
48
|
+
app = HpcMonitorApp()
|
|
49
|
+
all_passed = True
|
|
50
|
+
|
|
51
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
52
|
+
await pilot.pause()
|
|
53
|
+
|
|
54
|
+
# Save screenshot
|
|
55
|
+
screenshot_path = Path("tui_snapshot.svg")
|
|
56
|
+
app.save_screenshot(str(screenshot_path))
|
|
57
|
+
|
|
58
|
+
print("=" * 60)
|
|
59
|
+
print("TUI SNAPSHOT REVIEW")
|
|
60
|
+
print("=" * 60)
|
|
61
|
+
print(f"\nScreenshot saved to: {screenshot_path.absolute()}")
|
|
62
|
+
print(f"Theme: {app.theme}")
|
|
63
|
+
print(f"ANSI mode: {app.ansi_color}")
|
|
64
|
+
|
|
65
|
+
# Check Screen background
|
|
66
|
+
print("\n--- Background Transparency ---")
|
|
67
|
+
screen_bg = app.screen.styles.background
|
|
68
|
+
screen_ok = _is_transparent(screen_bg)
|
|
69
|
+
status = "✓ PASS" if screen_ok else "✗ FAIL"
|
|
70
|
+
print(f" Screen background: {_color_hex(screen_bg)} {status}")
|
|
71
|
+
if not screen_ok:
|
|
72
|
+
all_passed = False
|
|
73
|
+
print(" ^ Should be transparent for terminal background to show")
|
|
74
|
+
|
|
75
|
+
# Check Header
|
|
76
|
+
header = app.query_one(Header)
|
|
77
|
+
header_bg = header.styles.background
|
|
78
|
+
header_ok = _is_transparent(header_bg)
|
|
79
|
+
status = "✓ PASS" if header_ok else "✗ FAIL"
|
|
80
|
+
print(f" Header background: {_color_hex(header_bg)} {status}")
|
|
81
|
+
if not header_ok:
|
|
82
|
+
all_passed = False
|
|
83
|
+
|
|
84
|
+
# Check custom footer (#footer HorizontalGroup)
|
|
85
|
+
footer = app.query_one("#footer", HorizontalGroup)
|
|
86
|
+
footer_bg = footer.styles.background
|
|
87
|
+
footer_ok = _is_transparent(footer_bg)
|
|
88
|
+
status = "✓ PASS" if footer_ok else "✗ FAIL"
|
|
89
|
+
print(f" Footer background: {_color_hex(footer_bg)} {status}")
|
|
90
|
+
if not footer_ok:
|
|
91
|
+
all_passed = False
|
|
92
|
+
|
|
93
|
+
# Check footer children (all should be transparent)
|
|
94
|
+
for child in footer.children:
|
|
95
|
+
child_bg = child.styles.background
|
|
96
|
+
child_ok = _is_transparent(child_bg)
|
|
97
|
+
status = "✓ PASS" if child_ok else "✗ FAIL"
|
|
98
|
+
child_classes = " ".join(child.classes) if child.classes else "(no class)"
|
|
99
|
+
print(f" Footer child ({child_classes}): {_color_hex(child_bg)} {status}")
|
|
100
|
+
if not child_ok:
|
|
101
|
+
all_passed = False
|
|
102
|
+
|
|
103
|
+
# Check TabbedContent
|
|
104
|
+
tabbed = app.query_one(TabbedContent)
|
|
105
|
+
tabbed_bg = tabbed.styles.background
|
|
106
|
+
tabbed_ok = _is_transparent(tabbed_bg)
|
|
107
|
+
status = "✓ PASS" if tabbed_ok else "✗ FAIL"
|
|
108
|
+
print(f" TabbedContent background: {_color_hex(tabbed_bg)} {status}")
|
|
109
|
+
if not tabbed_ok:
|
|
110
|
+
all_passed = False
|
|
111
|
+
|
|
112
|
+
# Check tabs
|
|
113
|
+
print("\n--- Tab Styling ---")
|
|
114
|
+
tabs = app.query(Tab)
|
|
115
|
+
for tab in tabs:
|
|
116
|
+
is_active = tab.has_class("-active")
|
|
117
|
+
bg = tab.styles.background
|
|
118
|
+
|
|
119
|
+
if is_active:
|
|
120
|
+
# Active tab should have primary color (#88C0D0)
|
|
121
|
+
active_ok = bg is not None and bg.r == 136 and bg.g == 192 and bg.b == 208
|
|
122
|
+
status = "✓ PASS" if active_ok else "✗ FAIL"
|
|
123
|
+
print(f" Active tab '{tab.label.plain}': {_color_hex(bg)} {status}")
|
|
124
|
+
if not active_ok:
|
|
125
|
+
all_passed = False
|
|
126
|
+
print(" ^ Should be #88c0d0 (muted teal)")
|
|
127
|
+
else:
|
|
128
|
+
# Inactive tab should be transparent
|
|
129
|
+
inactive_ok = _is_transparent(bg)
|
|
130
|
+
status = "✓ PASS" if inactive_ok else "✗ FAIL"
|
|
131
|
+
print(f" Inactive tab '{tab.label.plain}': {_color_hex(bg)} {status}")
|
|
132
|
+
if not inactive_ok:
|
|
133
|
+
all_passed = False
|
|
134
|
+
|
|
135
|
+
# Summary
|
|
136
|
+
print("\n" + "=" * 60)
|
|
137
|
+
if all_passed:
|
|
138
|
+
print("RESULT: ✓ All visual checks passed")
|
|
139
|
+
else:
|
|
140
|
+
print("RESULT: ✗ Some checks failed - review needed!")
|
|
141
|
+
print("=" * 60)
|
|
142
|
+
|
|
143
|
+
return all_passed
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
"""Run snapshot review."""
|
|
148
|
+
passed = asyncio.run(capture_and_review())
|
|
149
|
+
sys.exit(0 if passed else 1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|