├── requirements.txt ├── slurmvision ├── scripts │ ├── __init__.py │ ├── slurmvision.yml │ └── __main__.py ├── __init__.py ├── slurm │ ├── __init__.py │ ├── utils.py │ └── tools.py └── tui │ ├── __init__.py │ ├── widgets.py │ └── slurmdash.py ├── setup.py ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | urwid 2 | ruamel.yaml 3 | -------------------------------------------------------------------------------- /slurmvision/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | from .__main__ import * 2 | -------------------------------------------------------------------------------- /slurmvision/__init__.py: -------------------------------------------------------------------------------- 1 | from .slurm import * 2 | from .tui import * 3 | -------------------------------------------------------------------------------- /slurmvision/slurm/__init__.py: -------------------------------------------------------------------------------- 1 | from .tools import SlurmThread, Job, Inspector 2 | -------------------------------------------------------------------------------- /slurmvision/tui/__init__.py: -------------------------------------------------------------------------------- 1 | from .slurmdash import Tui 2 | from .widgets import SelectableColumns 3 | -------------------------------------------------------------------------------- /slurmvision/slurm/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def slurm_check(): 5 | cmds = ["squeue", "sinfo"] 6 | try: 7 | subprocess.check_output(["squeue"]) 8 | except (FileNotFoundError, subprocess.CalledProcessError): 9 | raise RuntimeError( 10 | "Unable to communicate with SLURM services. Check SLURM/cluster status" 11 | ) 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.install import install 3 | import os 4 | 5 | NAME = "slurmvision" 6 | VERSION = "0.0" 7 | 8 | 9 | class InstallScript(install): 10 | def run(self): 11 | install.run(self) 12 | 13 | 14 | with open("requirements.txt", "r") as f: 15 | install_requires = list( 16 | filter(lambda x: "#" not in x, (line.strip() for line in f)) 17 | ) 18 | 19 | setup( 20 | name=NAME, 21 | version=VERSION, 22 | author="Nick Charron", 23 | author_email="charron.nicholas.e@gmail.com", 24 | url="https://github.com/ruunyox/slurmvision", 25 | license="MIT", 26 | packages=find_packages(), 27 | install_requires=install_requires, 28 | zip_safe=True, 29 | cmdclass={"install": InstallScript}, 30 | entry_points={ 31 | "console_scripts": ["slurmvision = slurmvision.scripts.__main__:main"], 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License (MIT) 2 | 3 | Copyright (c) 2022 Nicholas Edward Charron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /slurmvision/scripts/slurmvision.yml: -------------------------------------------------------------------------------- 1 | delimeter: "|||" 2 | squeue_opts: 3 | polling_interval: 10 4 | getopts: null 5 | formopts: 6 | "--Format": "JobId:|||,UserName:|||,Name:|||,STATE:|||,ReasonList:|||,TimeUsed:" 7 | sinfo_opts: 8 | getopts: null 9 | formopts: 10 | "--Format": "PartitionName:|||,Time:|||,CPUs:|||,Memory:|||,Gres:|||,StateCompact:" 11 | detail_opts: 12 | formopts: 13 | "--Format": "JobId:|||,UserName:|||,Name:|||,STATE:|||,Reason:|||,cpus-per-task:|||,Partition:|||,TimeUsed:|||,TimeLeft:|||,SubmitTime:|||,StartTime:|||,STDOUT:|||,WorkDir:|||,ClusterFeature:|||,Feature:|||,GroupName:|||,NumCPUs:|||,NumNodes:|||,NodeList:" 14 | tui_opts: 15 | select_advance: true 16 | palette: 17 | - 18 | # Color of job/cluster window 19 | - "standard" 20 | - "white" 21 | - "dark magenta" 22 | - 23 | # Color of header window 24 | - "header" 25 | - "black" 26 | - "white" 27 | - 28 | # Color of footer window 29 | - "footer" 30 | - "black" 31 | - "white" 32 | - 33 | # Color of jobs that have been selected 34 | - "selected" 35 | - "dark red" 36 | - "dark magenta" 37 | - 38 | # Color of warning pop-ups 39 | - "warning" 40 | - "black" 41 | - "dark red" 42 | - 43 | # Color of help messages 44 | - "help" 45 | - "black" 46 | - "yellow" 47 | - 48 | # Color of detailed job info pop-ups 49 | - "detail" 50 | - "black" 51 | - "white" 52 | - 53 | # Color of error pop-ups 54 | - "error" 55 | - "black" 56 | - "dark red" 57 | - 58 | # Color of currently highlighted job 59 | - "focus" 60 | - "black" 61 | - "dark magenta" 62 | -------------------------------------------------------------------------------- /slurmvision/tui/widgets.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Tuple, Union, Callable 2 | import urwid 3 | from urwid.command_map import ACTIVATE 4 | import subprocess 5 | 6 | 7 | class SelectableColumns(urwid.Columns): 8 | """Custom urwid.Columns child class that imbues a 9 | string column with a specific SLURM job ID and 10 | selectable/signal-emitting attributes/methods 11 | to emulate `urwid.Button` behavior 12 | 13 | Parameters 14 | ---------- 15 | JOBID: 16 | string of numerical SLURM job ID 17 | """ 18 | 19 | signals = ["click"] 20 | 21 | def __init__(self, *args, **kwargs): 22 | self.job_id = kwargs["JOBID"] 23 | del kwargs["JOBID"] 24 | super(SelectableColumns, self).__init__(*args, **kwargs) 25 | 26 | def selectable(self): 27 | return True 28 | 29 | def keypress(self, size, key): 30 | if self._command_map[key] != ACTIVATE: 31 | return key 32 | else: 33 | self._emit("click") 34 | 35 | 36 | class TailText(urwid.Text): 37 | """Implementation of tail box, given a filepath to read. 38 | I prefer to not implement reverse file reading today, 39 | so as of now, the "tail" command is used to grab text 40 | via a subprocess call (yes I know its ugly). Sorry, bubz. 41 | This is essentially a Text widget infused with a "tail" call. 42 | """ 43 | 44 | def __init__(self, file_path, num_lines: int = 10): 45 | self.file_path = file_path 46 | self.num_lines = num_lines 47 | stdout = TailText._read_lines(self.file_path, self.num_lines) 48 | super(TailText, self).__init__(stdout, wrap="ellipsis") 49 | 50 | @staticmethod 51 | def _read_lines(file_path, num_lines) -> str: 52 | out = subprocess.run( 53 | ["tail", "-n", str(num_lines), file_path], capture_output=True 54 | ) 55 | return out.stdout 56 | 57 | def refresh(self, *args, **kwargs): 58 | """Run refresh tail call and updates Text widget text""" 59 | stdout = TailText._read_lines(self.file_path, self.num_lines) 60 | self.set_text(stdout) 61 | -------------------------------------------------------------------------------- /slurmvision/scripts/__main__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from slurmvision.slurm.utils import slurm_check 4 | from slurmvision.tui import Tui 5 | from slurmvision.slurm import Inspector 6 | import argparse 7 | from ruamel.yaml import YAML 8 | import os 9 | from time import sleep 10 | 11 | 12 | def parse_arguments(): 13 | parser = argparse.ArgumentParser( 14 | description="TUI for inspecting and managing SLURM jobs." 15 | ) 16 | parser.add_argument( 17 | "--config", 18 | default=None, 19 | type=str, 20 | help="Specifies the absolute path to a slurmvision configuration YAML", 21 | ) 22 | return parser 23 | 24 | 25 | def main(): 26 | slurm_check() 27 | 28 | yaml = YAML(typ="safe") 29 | yaml.default_flow_style = False 30 | 31 | parser = parse_arguments() 32 | opts = parser.parse_args() 33 | 34 | if opts.config != None: 35 | with open(opts.config, "r") as yfile: 36 | config = yaml.load(yfile) 37 | else: 38 | default_config_dir = os.path.join(os.environ["HOME"], ".config/slurmvision.yml") 39 | try: 40 | with open(default_config_dir, "r") as yfile: 41 | config = yaml.load(yfile) 42 | except: 43 | print( 44 | f"Unable to load user config at {default_config_dir}. Proceeding with default options..." 45 | ) 46 | outer_keys = ["squeue_opts", "sinfo_opts", "detail_opts", "tui_opts"] 47 | config = {outer_key: {} for outer_key in outer_keys} 48 | config["delimeter"] = " " 49 | config["squeue_opts"] = { 50 | "polling_interval": 10, 51 | "getopts": None, 52 | "formopts": { 53 | "--Format": "JobId: ,UserName: ,Name: ,STATE: ,ReasonList: ,TimeUsed: " 54 | }, 55 | } 56 | config["sinfo_opts"] = { 57 | "getopts": None, 58 | "formopts": {"-o": "%10P %5c %5a %10l %20G %4D %6t"}, 59 | } 60 | config["detail_opts"] = { 61 | "formopts": { 62 | "--Format": "JobId: ,UserName: ,Name: ,STATE: ,Reason:, Nodes: ,NumCPUs: ,cpus-per-task: ,Partition: ,TimeUsed: ,TimeLeft: ,SubmitTime: ,StartTime: ,STDOUT: ,WorkDir: " 63 | } 64 | } 65 | config["tui_opts"] = {"select_advance": True, "my_jobs_first": True} 66 | 67 | if "palette" not in list(config["tui_opts"].keys()): 68 | config["tui_opts"]["palette"] = None 69 | 70 | inspector = Inspector( 71 | polling_interval=config["squeue_opts"]["polling_interval"], 72 | squeue_getopts=config["squeue_opts"]["getopts"], 73 | squeue_formopts=config["squeue_opts"]["formopts"], 74 | sinfo_getopts=config["sinfo_opts"]["getopts"], 75 | sinfo_formopts=config["sinfo_opts"]["formopts"], 76 | detail_formopts=config["detail_opts"]["formopts"], 77 | delimeter=config["delimeter"], 78 | ) 79 | tui = Tui(inspector, **config["tui_opts"]) 80 | tui.start() 81 | 82 | 83 | if __name__ == "__main__": 84 | main() 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlurmVision 2 | 3 | --- 4 | 5 | Simple tool for browsing, inspecting, canceling SLURM jobs. Greatly inspired by 6 | mil-ad's [stui](https://github.com/mil-ad/stui). Please be aware of your cluster's rules (if any) concerning 7 | 'squeue'/'sinfo' polling request frequency. Currently a minimum of 10 seconds for a polling interval 8 | is suggested by default. If you wish to poll more frequently, do so at your own risk (per the license) 9 | and/or after consultation with your cluster admin(s). Enjoy! 10 | 11 | ![slurmvision-a-tui-for-monitoring-inspecting-and-canceling-v0-c2x03yi3oz0a1](https://github.com/Ruunyox/slurmvision/assets/42926839/701ae6d0-6917-4f54-b59e-2f5330b08803) 12 | 13 | 14 | ## Install 15 | 16 | ``` 17 | git clone https://github.com/ruunyox/slurmvision 18 | cd slurmvision 19 | pip3 install . 20 | ``` 21 | 22 | ## Usage 23 | 24 | `slurmvision --help` 25 | 26 | Press `h` for information on controls while running. 27 | 28 | ## Configuration 29 | 30 | A user-specific YAML file of configuration options can be read from `$HOME/.config/slurmvision.yml` or the `--config` CLI argument can be used to specify a config file elsewhere. A sample configuration file is shown here: 31 | 32 | ``` 33 | delimeter: "|||" 34 | squeue_opts: 35 | polling_interval: 10 36 | getopts: null 37 | formopts: 38 | "--Format": "JobId:|||,UserName:|||,Name:|||,STATE:|||,ReasonList:|||,TimeUsed:" 39 | sinfo_opts: 40 | getopts: null 41 | formopts: 42 | "--Format": "PartitionName:|||,Time:|||,CPUs:|||,Memory:|||,Gres:|||,StateCompact:" 43 | detail_opts: 44 | formopts: 45 | "--Format": "JobId:|||,UserName:|||,Name:|||,STATE:|||,Reason:|||,cpus-per-task:|||,Partition:|||,TimeUsed:|||,TimeLeft:|||,SubmitTime:|||,StartTime:|||,STDOUT:|||,WorkDir:|||,ClusterFeature:|||,Feature:|||,GroupName:|||,NumCPUs:|||,NumNodes:|||,NodeList:" 46 | tui_opts: 47 | select_advance: true 48 | my_jobs_first: true 49 | palette: null 50 | ``` 51 | 52 | The user configuration can also specify a specific palette using standard Urwid named colors as a nested list: 53 | 54 | ``` 55 | # all color specifications are represented by ["name", "fg", "bg"] 56 | tui_opts: 57 | palette: 58 | - 59 | # Color of job/cluster window 60 | - "standard" 61 | - "white" 62 | - "dark magenta" 63 | - 64 | # Color of header window 65 | - "header" 66 | - "black" 67 | - "white" 68 | - 69 | # Color of footer window 70 | - "footer" 71 | - "black" 72 | - "white" 73 | - 74 | # Color of jobs that have been selected 75 | - "selected" 76 | - "dark red" 77 | - "dark magenta" 78 | - 79 | # Color of warning pop-ups 80 | - "warning" 81 | - "black" 82 | - "dark red" 83 | - 84 | # Color of help messages 85 | - "help" 86 | - "black" 87 | - "yellow" 88 | - 89 | # Color of detailed job info pop-ups 90 | - "detail" 91 | - "black" 92 | - "white" 93 | - 94 | # Color of error pop-ups 95 | - "error" 96 | - "black" 97 | - "dark red" 98 | - 99 | # Color of currently highlighted job 100 | - "focus" 101 | - "black" 102 | - "dark magenta" 103 | ``` 104 | Any unspecified palette options will assume default options. 105 | -------------------------------------------------------------------------------- /slurmvision/slurm/tools.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Tuple, Union, Callable 2 | import subprocess 3 | import json 4 | from threading import Thread, Event 5 | from time import sleep 6 | import os 7 | import warnings 8 | 9 | MAX_CHAR = 256 10 | 11 | 12 | class Job(object): 13 | """Class for storing metadata about a SLURM job 14 | 15 | Parameters 16 | ---------- 17 | attrs: 18 | Dictionary of SQUEUE header/job attribute key value pairs 19 | """ 20 | 21 | _valid_fields = None 22 | 23 | def __init__(self, attrs: Dict[str, str]): 24 | self.attrs = attrs 25 | 26 | def __str__(self) -> str: 27 | return json.dumps(self.attrs, indent=2) 28 | 29 | 30 | class Inspector(object): 31 | """Requests and Stores SQUEUE and SINFO output 32 | through subprocess calls 33 | 34 | Parameters 35 | ---------- 36 | polling_interval: 37 | Number of seconds to wait in between each SQUEUE subprocess call. 38 | Please set to at least 10 seconds (or your cluster managers may get 39 | mad at you :) ) 40 | squeue_getopts: 41 | Dictionary of flag/argument key/value pairs for use with SQUEUE. 42 | See https://slurm.schedmd.com/squeue.html for more information. 43 | squeue_formopts: 44 | Single entry dictionary with the key -O/--Format and the value 45 | specifying valid SQUEUE extended formatting options. See 46 | https://slurm.schedmd.com/squeue.html for more information. 47 | sinfo_getopts: 48 | Dictionary of flag/argument key/value pairs for use with SINFO. 49 | See https://slurm.schedmd.com/sinfo.html for more information. 50 | sinfo_formopts: 51 | Single entry dictionary with the key -o/--format and the value 52 | specifying valid SINFO formatting options. See 53 | https://slurm.schedmd.com/sinfo.html for more information. 54 | detail_formopts: 55 | Single entry dictionary with the key -O/--Format and the value 56 | specifying valid SQUEUE extended formatting options. See 57 | https://slurm.schedmd.com/squeue.html for more information. For 58 | use in inspecting single, specifc jobs. 59 | delimeter: 60 | Delimeter string used to separate the feilds of both headers and bodys 61 | of SQUEUE, SINFO, and DETAIL call outputs. This one delimeter choice 62 | applies to all STDOUT parsing. 63 | """ 64 | 65 | def __init__( 66 | self, 67 | polling_interval: float = 10, 68 | squeue_getopts: Optional[Dict[str, str]] = None, 69 | squeue_formopts: Optional[Dict[str, str]] = None, 70 | sinfo_getopts: Optional[Dict[str, str]] = None, 71 | sinfo_formopts: Optional[Dict[str, str]] = None, 72 | detail_formopts: Optional[Dict[str, str]] = None, 73 | delimeter: Optional[str] = " ", 74 | ): 75 | self.delimeter = delimeter 76 | if polling_interval < 10: 77 | warnings.warn( 78 | f"\n\nWARNING: your current polling_interval, {polling_interval} seconds, may be a bit low depending on your cluster's setup and typical usage. Use a higher polling rate at your own risk or clear it with your cluster admins. Remember that you can manually refresh the SQUEUE/SINFO output using the 'p' keystroke in the TUI.\n\nStarting TUI in 5 s... " 79 | ) 80 | sleep(5) 81 | self.polling_interval = polling_interval 82 | if squeue_getopts != None: 83 | self.squeue_getopts = squeue_getopts 84 | else: 85 | self.squeue_getopts = {} 86 | 87 | if sinfo_getopts != None: 88 | self.sinfo_getopts = sinfo_getopts 89 | else: 90 | self.sinfo_getopts = {} 91 | 92 | if squeue_formopts == None: 93 | self.squeue_formopts = { 94 | "-O": f"JobId,UserName,Name:{MAX_CHAR},STATE,ReasonList,TimeUsed" 95 | } 96 | else: 97 | assert len(squeue_formopts) == 1 98 | self.squeue_formopts = squeue_formopts 99 | 100 | if sinfo_formopts == None: 101 | self.sinfo_formopts = {"-o": "%10P %5c %5a %10l %20G %4D %6t"} 102 | else: 103 | assert len(sinfo_formopts) == 1 104 | self.sinfo_formopts = sinfo_formopts 105 | 106 | if detail_formopts == None: 107 | self.detail_formopts = { 108 | "-O": f"JobId:{MAX_CHAR},UserName:{MAX_CHAR},Name:{MAX_CHAR},STATE:{MAX_CHAR},Reason:{MAX_CHAR},Nodes:{MAX_CHAR},NumCPUs:{MAX_CHAR},cpus-per-task:{MAX_CHAR},Partition:{MAX_CHAR},TimeUsed:{MAX_CHAR},TimeLeft:{MAX_CHAR},SubmitTime:{MAX_CHAR},StartTime:{MAX_CHAR},STDOUT:{MAX_CHAR},WorkDir:{MAX_CHAR}" 109 | } 110 | else: 111 | assert len(detail_formopts) == 1 112 | self.detail_formopts = detail_formopts 113 | 114 | self.jobs = [] 115 | self.squeue_header = None 116 | self.sinfo = [] 117 | self.sinfo_header = None 118 | self.get_info() 119 | self.user = os.environ["USER"] 120 | self.detail_info = None 121 | self.detail_info_header = None 122 | 123 | def toggle_user_filter(self, *args): 124 | """Toggles filtering of user-only jobs""" 125 | if "-u" not in list(self.squeue_getopts.keys()): 126 | self.squeue_getopts["-u"] = self.user 127 | else: 128 | del self.squeue_getopts["-u"] 129 | 130 | def toggle_running_filter(self, *args): 131 | """Toggles filtering of running jobs""" 132 | if "--state" not in list(self.squeue_getopts.keys()): 133 | self.squeue_getopts["--state"] = "RUNNING" 134 | else: 135 | del self.squeue_getopts["--state"] 136 | 137 | @staticmethod 138 | def parse_squeue_output(squeue_output: str, delim: str) -> Tuple[List[Job], str]: 139 | """Parses SQUEUE output and extracts jobs 140 | 141 | Parameters 142 | ---------- 143 | squeue_output: 144 | stdout string from SQUEUE subprocess call 145 | delim: 146 | string delimeter separating fields in the header and body 147 | of the SQUEUE call ouput 148 | 149 | Returns 150 | ------- 151 | jobs: 152 | List of `Job` instances for each job parsed from the SQUEUE output 153 | header: 154 | SQUEUE output header string 155 | """ 156 | 157 | lines = squeue_output.split("\n")[:-1] # Final line is a double return 158 | header = lines[0].split(delim) 159 | jobs = [] 160 | for line in lines[1:]: 161 | tokens = line.split(delim) 162 | assert len(tokens) == len(header) 163 | job = Job({h: t for h, t in zip(header, tokens)}) 164 | jobs.append(job) 165 | return jobs, header 166 | 167 | @staticmethod 168 | def parse_sinfo_output( 169 | sinfo_output: str, delim: str 170 | ) -> Tuple[List[Dict[str, str]], str]: 171 | """Parses SINFO output 172 | 173 | Parameters 174 | ---------- 175 | sinfo_output: 176 | stdout string from SINFO subprocess call 177 | delim: 178 | string delimeter separating fields in the header and body 179 | of the SQUEUE call ouput 180 | 181 | Returns 182 | ------- 183 | strs: 184 | List of dictionaries, where each dictionary contains an sinfo output 185 | row parsed into SINFO header/value key/value pairs 186 | header: 187 | SINFO output header string 188 | """ 189 | 190 | lines = sinfo_output.split("\n")[:-1] # Final line is a double return 191 | header = lines[0].split(delim) 192 | strs = [] 193 | for line in lines[1:]: 194 | tokens = line.split(delim) 195 | assert len(tokens) == len(header) 196 | str_ = {h: t for h, t in zip(header, tokens)} 197 | strs.append(str_) 198 | return strs, header 199 | 200 | @staticmethod 201 | def build_s_cmd( 202 | base_cmd: str = "squeue", 203 | getopts: Optional[Dict[str, str]] = None, 204 | formopts: Optional[Dict[str, str]] = None, 205 | ) -> List[str]: 206 | """Constructs SQUEUE/SINFO command for subprocess call 207 | 208 | Parameters 209 | ---------- 210 | base_cmd: 211 | string representing the base command to use in a subprocess call 212 | getopts: 213 | Non-formatting based option dictionary constructed from flag/argument 214 | key/value pairs. See https://slurm.schedmd.com/sinfo.html and 215 | https://slurm.schedmd.com/squeue.html for more information. 216 | formopts: 217 | Formating option dictionary constructed from a single entry flag/argument 218 | key/value pair. See https://slurm.schedmd.com/sinfo.html and 219 | https://slurm.schedmd.com/sinfo.html for more information. 220 | 221 | Returns 222 | ------- 223 | cmd: 224 | List of strings representing command tokens for subprocess calls 225 | """ 226 | 227 | cmd = [base_cmd] 228 | if getopts != None: 229 | for optarg in getopts.items(): 230 | cmd.extend(optarg) 231 | if formopts != None: 232 | cmd.extend(list(formopts.items())[0]) 233 | return cmd 234 | 235 | def get_jobs(self): 236 | """Populates the jobs and squeue_header attributes according to 237 | user-specified SQUEUE options. 238 | """ 239 | 240 | squeue_cmd = Inspector.build_s_cmd( 241 | "squeue", self.squeue_getopts, self.squeue_formopts 242 | ) 243 | cmd_output = subprocess.run(squeue_cmd, capture_output=True) 244 | squeue_output = cmd_output.stdout.decode("utf-8") 245 | self.jobs, self.squeue_header = Inspector.parse_squeue_output( 246 | squeue_output, self.delimeter 247 | ) 248 | 249 | def get_info(self): 250 | """Populated the sinfo and sinfo_header attributes according to 251 | user-specified SINFO options. 252 | """ 253 | 254 | sinfo_cmd = Inspector.build_s_cmd( 255 | "sinfo", self.sinfo_getopts, self.sinfo_formopts 256 | ) 257 | cmd_output = subprocess.run(sinfo_cmd, capture_output=True) 258 | sinfo_output = cmd_output.stdout.decode("utf-8") 259 | self.sinfo, self.sinfo_header = Inspector.parse_sinfo_output( 260 | sinfo_output, self.delimeter 261 | ) 262 | 263 | def get_job_details(self, job_id: str) -> Job: 264 | """Get detailed information for a single specific job to store 265 | in the job_info attribute according to the job_formopts specifications 266 | 267 | Parameters 268 | ---------- 269 | job_id: 270 | String of the numerical SLURM job ID for which detailed information 271 | has been requested 272 | 273 | Returns 274 | ------- 275 | detail_info: 276 | Job with attributes populated according to specified detail 277 | """ 278 | 279 | detail_cmd = Inspector.build_s_cmd( 280 | "squeue", {"-j": job_id}, self.detail_formopts 281 | ) 282 | cmd_output = subprocess.run(detail_cmd, capture_output=True) 283 | detail_output = cmd_output.stdout.decode("utf-8") 284 | detail_info, _ = Inspector.parse_squeue_output(detail_output, self.delimeter) 285 | return detail_info[0] 286 | 287 | def cancel_job(self, job_id: str) -> subprocess.CompletedProcess: 288 | """Calls SCANCEL on the specified job ID. 289 | 290 | Parameters 291 | ---------- 292 | job_id: 293 | String specifying the numerical SLURM job ID associated with the 294 | job to be cancelled 295 | 296 | Return 297 | ------ 298 | output: 299 | Subprocess output for checking command success/failure 300 | """ 301 | 302 | output = subprocess.run(["scancel", job_id], capture_output=True) 303 | return output 304 | 305 | 306 | class SlurmThread(Thread): 307 | """Thread for periodically polling SLURM info 308 | 309 | Parameters 310 | ---------- 311 | inspector: 312 | Inspector instance from which SLURM commands may 313 | be subprocessed 314 | """ 315 | 316 | def __init__(self, inspector: Inspector): 317 | Thread.__init__(self) 318 | self.stop_event = Event() 319 | self.inspector = inspector 320 | self.event_check_interval = 1 321 | 322 | def run(self): 323 | """Main thread polling loop. Sleeps after each self.event_check_interval, 324 | but runs an inspector poll every self.inspector.polling_interval 325 | """ 326 | counts = 1 327 | while not self.stop_event.isSet(): 328 | if (self.inspector.polling_interval) / counts < 1.0: 329 | self.inspector.get_jobs() 330 | counts = 1 331 | continue 332 | sleep(self.event_check_interval) 333 | counts = counts + 1 334 | 335 | def join(self, timeout: Union[int, None] = None): 336 | """Safely request thread to end 337 | 338 | Parameters 339 | ---------- 340 | timeout: 341 | Number of seconds to wait before thread join attempt ends. 342 | """ 343 | self.stop_event.set() 344 | Thread.join(self, timeout=timeout) 345 | -------------------------------------------------------------------------------- /slurmvision/tui/slurmdash.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Tuple, Union, Callable 2 | import urwid 3 | from urwid.command_map import ACTIVATE 4 | from copy import deepcopy 5 | import os 6 | from .widgets import SelectableColumns, TailText 7 | from ..slurm import Inspector, SlurmThread 8 | 9 | default_palette = { 10 | "standard": ("standard", "", ""), 11 | "header": ("header", "brown", ""), 12 | "footer": ("footer", "brown", ""), 13 | "selected": ("selected", "dark red", ""), 14 | "warning": ("warning", "white", "dark red"), 15 | "help": ("help", "black", "yellow"), 16 | "detail": ("detail", "black", "white"), 17 | "tail": ("tail", "white", "dark green"), 18 | "error": ("error", "dark red", "white"), 19 | "focus": ("focus", "dark blue", ""), 20 | } 21 | 22 | 23 | class Tui(object): 24 | """SlurmVision urwid-based Text-user interface for 25 | browsing, tracking, canceling, and querying job info 26 | from a local SLURM database 27 | 28 | Parameters 29 | ---------- 30 | inspector: 31 | `Inspector` instance that handles SQUEUE/SINFO query 32 | and storage. 33 | select_advance: 34 | If True, the cursor for the job list is advanced forward 35 | automatically when a (de)selection is performed. 36 | my_jobs_first: 37 | If True, slurmvision will start with "My Jobs" toggled ON. 38 | Useful if you are working with a big/busy cluster. 39 | palette: 40 | Nested list of lists/tuples specifying color options for 41 | the TUI. See https://urwid.org/manual/displayattributes.html 42 | for more information on color/palette options. If None, 43 | the default palette is used. If palette options are missing, 44 | they will be filled with default color options. 45 | """ 46 | 47 | _help_strs = [ 48 | "space/enter -> Select/deselect jobs", 49 | "m -> Toggle user jobs", 50 | "r -> Toggle running jobs", 51 | "p -> Manual SQUEUE/SINFO poll", 52 | "i -> View SINFO output", 53 | "j -> View jobs", 54 | "c -> Deselect all currently selected jobs", 55 | "d -> Detailed view of currently highlighted job", 56 | "t -> Tail ouput for the currently highlighed job", 57 | "/ -> Global search", 58 | "bksp -> Cancel selected jobs", 59 | "tab -> Refocus to job panel/leave search box", 60 | "q -> Quit", 61 | ] 62 | 63 | _box_style_kwargs = { 64 | "tline": "\N{BOX DRAWINGS DOUBLE HORIZONTAL}", 65 | "trcorner": "\N{BOX DRAWINGS DOWN SINGLE AND LEFT DOUBLE}", 66 | "tlcorner": "\N{BOX DRAWINGS DOWN SINGLE AND RIGHT DOUBLE}", 67 | } 68 | 69 | def __init__( 70 | self, 71 | inspector: Inspector, 72 | select_advance: bool = True, 73 | my_jobs_first: bool = False, 74 | palette: Optional[List[List[str]]] = None, 75 | ): 76 | if palette: 77 | defined_items = set([color_opt[0] for color_opt in palette]) 78 | full_items = set(default_palette.keys()) 79 | undefined_items = full_items - defined_items 80 | for ui in undefined_items: 81 | palette.append(default_palette[ui]) 82 | self.palette = palette 83 | else: 84 | self.palette = [val for val in default_palette.values()] 85 | 86 | self.num_tail_lines = 8 87 | self.tail_sleep = 1 88 | self.inspector = inspector 89 | self.selected_jobs = set() 90 | self.my_jobs_first = my_jobs_first 91 | 92 | if self.my_jobs_first: 93 | self.inspector.toggle_user_filter() 94 | 95 | self.inspector.get_jobs() 96 | self.view = "squeue" 97 | self.filter_str = "" 98 | self.top = self._create_top() 99 | self.select_advance = select_advance 100 | self.loop = urwid.MainLoop( 101 | self.top, palette=self.palette, unhandled_input=self._handle_input 102 | ) 103 | self.handle = self.loop.set_alarm_in( 104 | sec=self.inspector.polling_interval, callback=self.update_top 105 | ) 106 | self.tail_handle = None 107 | 108 | def start(self): 109 | """Creates and starts the polling thread as well as the TUI main loop""" 110 | self.poll_thread = SlurmThread(self.inspector) 111 | self.poll_thread.start() 112 | self.loop.run() 113 | 114 | def _urwid_quit(self, *args): 115 | """Deconstructs the TUI and quits the program""" 116 | self.poll_thread.join() 117 | self.loop.remove_alarm(self.handle) 118 | raise urwid.ExitMainLoop() 119 | print("Polling thread closing... either wait or CTRL-C.") 120 | 121 | def _handle_input(self, key: str): 122 | """Handles general keyboard input during the TUI loop 123 | 124 | Parameters 125 | ---------- 126 | key: 127 | String representing the user input 128 | """ 129 | if key in ("Q", "q"): 130 | self._urwid_quit() 131 | if key in ("J", "j"): 132 | self._set_view("squeue") 133 | if key in ("I", "i"): 134 | self._set_view("sinfo") 135 | if key in ("M", "m"): 136 | self._toggle_my_jobs() 137 | if key in ("R", "r"): 138 | self._toggle_running_jobs() 139 | if key in ("C", "c"): 140 | if self.view == "squeue": 141 | self._deselect_check() 142 | if key in ("P", "p"): 143 | if self.view == "squeue": 144 | self.inspector.get_jobs() 145 | if self.view == "sinfo": 146 | self.inspector.get_info() 147 | self.loop.remove_alarm(self.handle) 148 | self.update_top() 149 | if key in ("D", "d"): 150 | if self.view == "squeue": 151 | self._inspect_detail() 152 | if key in ("T", "t"): 153 | if self.view == "squeue": 154 | self._inspect_tail() 155 | if key == "/": 156 | if self.view == "squeue": 157 | self._enter_search() 158 | if key == "backspace": 159 | if self.view == "squeue": 160 | if len(self.selected_jobs) > 0: 161 | self._scancel_check() 162 | if key == "tab": 163 | self.top.focus_position = "body" 164 | if key in ("H", "h"): 165 | self._help_box() 166 | 167 | def _inspect_detail(self): 168 | """Creates a pop-up with detailed job info for the currently highlighted job""" 169 | current_focus = self.top.body.original_widget.original_widget.body.focus 170 | row = self.top.body.original_widget.original_widget.body[current_focus] 171 | job_id = row.original_widget.job_id 172 | detail_info = self.inspector.get_job_details(job_id) 173 | self._info_box(detail_info.attrs) 174 | 175 | def _inspect_tail(self): 176 | """Creates a pop-up with tailbox for the slurm output of the currently 177 | highlighted job 178 | """ 179 | current_focus = self.top.body.original_widget.original_widget.body.focus 180 | row = self.top.body.original_widget.original_widget.body[current_focus] 181 | job_id = row.original_widget.job_id 182 | file_path = self.inspector.get_job_details(job_id).attrs["STDOUT"] 183 | self._tail_box(file_path) 184 | 185 | def _scancel_check(self): 186 | """Prompts the user with yes/no prompt to cancel selected jobs""" 187 | self._yes_no_prompt( 188 | f"Are you sure you want to cancel {len(self.selected_jobs)} selected job(s)?", 189 | self.cancel_selected_jobs, 190 | self._return_to_top, 191 | ) 192 | 193 | def _deselect_check(self): 194 | """Prompts the user with yes/no prompt for clearing current selection""" 195 | self._yes_no_prompt( 196 | f"Are you sure you want to clear selection?", 197 | self.clear_selection, 198 | self._return_to_top, 199 | ) 200 | 201 | def _enter_search(self): 202 | """Sets the focus on the footer search urwid.EditBox""" 203 | self.top.focus_position = "footer" 204 | self.top.footer.original_widget.original_widget.focus_col = 3 205 | 206 | def _set_view(self, view: str): 207 | """Sets main TUI view""" 208 | if view not in ["squeue", "sinfo"]: 209 | raise ValueError(f"'{view}' is not a valid TUI view") 210 | self.view = view 211 | self.draw_header() 212 | self.draw_body() 213 | 214 | def _return_to_top(self, *args): 215 | """Removes any currently overlaid widgets and returns to the TUI urwid.Frame widget""" 216 | if self.tail_handle is not None: 217 | self.loop.remove_alarm(self.tail_handle) 218 | self.tail_handle = None 219 | self.loop.widget = self.top 220 | 221 | def _toggle_my_jobs(self): 222 | """Toggles the user-only job filter in SQUEUE calls""" 223 | self.top.footer.original_widget.original_widget[0].toggle_state() 224 | 225 | def _toggle_running_jobs(self): 226 | """Toggles the RUNNING state job filter in SQUEUE calls""" 227 | self.top.footer.original_widget.original_widget[1].toggle_state() 228 | 229 | def _create_top(self) -> urwid.Frame: 230 | """Creates main urwid.Frame widget 231 | 232 | Returns 233 | ------- 234 | top: 235 | urwid.Frame instance containing a header for labeling SQUEUE/SINFO 236 | output columns, a body for displaying jobs/cluster options 237 | and a footer with useful widgets for filtering output. 238 | """ 239 | headstr = self.build_headstr(self.inspector.squeue_header) 240 | footstr = self.build_footstr() 241 | jstrs = self.build_squeue_list() 242 | 243 | job_list = urwid.SimpleFocusListWalker(jstrs) 244 | self.list_focus_pos = 0 245 | job_win = urwid.ListBox(job_list) 246 | job_linebox = urwid.LineBox(job_win, **Tui._box_style_kwargs) 247 | body = urwid.AttrMap(job_linebox, "standard", None) 248 | 249 | top = urwid.Frame( 250 | body, 251 | header=urwid.AttrMap( 252 | urwid.LineBox(headstr, title="SlurmVision", **Tui._box_style_kwargs), 253 | "header", 254 | None, 255 | ), 256 | footer=urwid.AttrMap( 257 | urwid.LineBox(footstr, **Tui._box_style_kwargs), "footer", None 258 | ), 259 | ) 260 | return top 261 | 262 | def _help_box(self): 263 | """Creates temporary Help overlay displaying 264 | command keystrokes for using the TUI. 265 | """ 266 | if not isinstance(self.loop.widget, urwid.Overlay): 267 | ok = urwid.Button("OK") 268 | urwid.connect_signal(ok, "click", self._return_to_top) 269 | 270 | help_text = [urwid.Divider()] 271 | for s in Tui._help_strs: 272 | help_text.append(urwid.Padding(urwid.Text(s), left=3, right=3)) 273 | help_text.append(urwid.Divider()) 274 | help_text.append(ok) 275 | 276 | help_pile = urwid.Pile(help_text) 277 | help_box = urwid.AttrMap( 278 | urwid.LineBox(help_pile, title="Help", **Tui._box_style_kwargs), 279 | "help", 280 | None, 281 | ) 282 | 283 | w = urwid.Overlay( 284 | urwid.AttrMap(urwid.Filler(help_box), "standard", None), 285 | self.top, 286 | align="center", 287 | width=("relative", 80), 288 | valign="middle", 289 | height=len(help_text) + 4, 290 | top=2, 291 | bottom=2, 292 | left=2, 293 | right=2, 294 | ) 295 | self.loop.widget = w 296 | 297 | def _info_box(self, str_pairs: Dict[str, str]): 298 | """Displays an information box given a mapping of attributes 299 | and descriptions 300 | 301 | Parameters 302 | ---------- 303 | str: 304 | Dictionary of field/values string pairs concerning 305 | specified job detail attributes. 306 | """ 307 | if not isinstance(self.loop.widget, urwid.Overlay): 308 | ok = urwid.Button("OK") 309 | urwid.connect_signal(ok, "click", self._return_to_top) 310 | 311 | infos = [] 312 | for key, value in str_pairs.items(): 313 | infos.append( 314 | urwid.Padding(urwid.Text(f"{key} : {value}"), right=3, left=3) 315 | ) 316 | 317 | info_walker = urwid.SimpleListWalker(infos) 318 | info_list = urwid.BoxAdapter(urwid.ListBox(info_walker), height=len(infos)) 319 | info_pile = urwid.Pile([info_list, urwid.Divider(), ok]) 320 | 321 | info_box = urwid.AttrMap( 322 | urwid.LineBox(info_pile, title="Job Detail", **Tui._box_style_kwargs), 323 | "detail", 324 | None, 325 | ) 326 | 327 | w = urwid.Overlay( 328 | urwid.AttrMap(urwid.Filler(info_box), "standard", None), 329 | self.top, 330 | align="center", 331 | width=("relative", 80), 332 | valign="middle", 333 | height=len(infos) + 4, 334 | top=2, 335 | bottom=2, 336 | left=2, 337 | right=2, 338 | ) 339 | self.loop.widget = w 340 | 341 | def _tail_refresh(self, *args): 342 | # Grab the top (-1) widget of the Overlay 343 | self.loop.widget.contents[1][ 344 | 0 345 | ].original_widget.original_widget.original_widget.original_widget.contents[0][ 346 | 0 347 | ].refresh() 348 | self.tail_handle = self.loop.set_alarm_in( 349 | sec=self.tail_sleep, callback=self._tail_refresh 350 | ) 351 | 352 | def _tail_box(self, file_path: str): 353 | """Displays tail box for STDOUT of selected job 354 | 355 | Parameters 356 | ---------- 357 | str: 358 | Dictionary of field/values string pairs concerning 359 | specified job detail attributes. 360 | """ 361 | if not isinstance(self.loop.widget, urwid.Overlay): 362 | ok = urwid.Button("OK") 363 | urwid.connect_signal(ok, "click", self._return_to_top) 364 | 365 | tail_text = TailText(file_path, self.num_tail_lines) 366 | tail_pile = urwid.Pile([tail_text, urwid.Divider(), ok]) 367 | tail_box = urwid.AttrMap( 368 | urwid.LineBox(tail_pile, title="Job Output", **Tui._box_style_kwargs), 369 | "tail", 370 | None, 371 | ) 372 | 373 | w = urwid.Overlay( 374 | urwid.AttrMap(urwid.Filler(tail_box), "standard", None), 375 | self.top, 376 | align="center", 377 | width=("relative", 80), 378 | valign="middle", 379 | height=self.num_tail_lines + 8, 380 | top=2, 381 | bottom=2, 382 | left=2, 383 | right=2, 384 | ) 385 | self.loop.widget = w 386 | self.tail_handle = self.loop.set_alarm_in( 387 | sec=self.tail_sleep, callback=self._tail_refresh 388 | ) 389 | 390 | def _error_box(self, error_msg: str): 391 | """Displays a temporary error message 392 | 393 | Parameters 394 | ---------- 395 | error_msg: 396 | Error message to be displayed to the user 397 | """ 398 | ok = urwid.Button("OK") 399 | urwid.connect_signal(ok, "click", self._return_to_top) 400 | 401 | error_text = [urwid.Divider()] 402 | error_text.append(urwid.Padding(urwid.Text(error_msg), left=3, right=3)) 403 | error_text.append(urwid.Divider()) 404 | error_text.append(ok) 405 | 406 | error_pile = urwid.Pile(error_text) 407 | error_box = urwid.AttrMap( 408 | urwid.LineBox(error_pile, title="Error", **Tui._box_style_kwargs), 409 | "error", 410 | None, 411 | ) 412 | 413 | w = urwid.Overlay( 414 | urwid.AttrMap(urwid.Filler(error_box), "standard", None), 415 | self.top, 416 | align="center", 417 | width=("relative", 60), 418 | valign="middle", 419 | height=6, 420 | ) 421 | self.loop.widget = w 422 | 423 | def _yes_no_prompt(self, prompt: str, yes_call: Callable, no_call: Callable): 424 | """Displays a prompt to the user and expects a yes/no 425 | answer. The two answers are signal-linked to "yes" and "no" 426 | callables. 427 | 428 | Parameters 429 | ---------- 430 | prompt: 431 | String asking the user a question. 432 | yes_call: 433 | Function that is called if "Yes" is chosen 434 | no_call: 435 | Function that is called if "No" is chosen 436 | """ 437 | yes = urwid.Button("Yes") 438 | no = urwid.Button("No") 439 | 440 | urwid.connect_signal(yes, "click", yes_call) 441 | urwid.connect_signal(no, "click", no_call) 442 | 443 | buttons = urwid.Columns([yes, no]) 444 | pile = urwid.Pile( 445 | [urwid.Text(prompt, align="center"), urwid.Divider(), buttons] 446 | ) 447 | message_box = urwid.AttrMap( 448 | urwid.LineBox(pile, title="Warning", **Tui._box_style_kwargs), 449 | "warning", 450 | None, 451 | ) 452 | 453 | w = urwid.Overlay( 454 | urwid.AttrMap(urwid.Filler(message_box), "standard", None), 455 | self.top, 456 | align="center", 457 | width=("relative", 30), 458 | valign="middle", 459 | height=6, 460 | ) 461 | self.loop.widget = w 462 | 463 | def cancel_selected_jobs(self, *args): 464 | """Instructs the inspector to cancel all currently 465 | selected jobs. If a job cannot be cancelled (e.g., if 466 | the user does not possess appropriate permissions), 467 | an error message is displayed relaying the stderr 468 | of the SCANCEL subprocess call. 469 | """ 470 | 471 | self._return_to_top() 472 | for job in deepcopy(self.selected_jobs): 473 | output = self.inspector.cancel_job(job) 474 | if output.stderr: 475 | self._error_box(output.stderr.decode("utf-8")) 476 | else: 477 | self.selected_jobs.remove(job) 478 | self.top.footer.original_widget.original_widget[2].set_text( 479 | f"[{len(self.selected_jobs)}] Selected" 480 | ) 481 | 482 | def clear_selection(self, *args): 483 | """Clears the users currently selected jobs""" 484 | self.selected_jobs = set() 485 | self.top.footer.original_widget.original_widget[2].set_text( 486 | f"[{len(self.selected_jobs)}] Selected" 487 | ) 488 | self._return_to_top() 489 | self.draw_body() 490 | 491 | def update_filter_str(self, edit: urwid.Edit, *args): 492 | """Updates job filtering string based on 'change' 493 | signals emitted from the footer 'Search' edit box. 494 | 495 | Parameters 496 | ---------- 497 | edit: 498 | urwid Edit box associated with the TUI footer 499 | 'Search' column 500 | """ 501 | 502 | self.filter_str = edit.get_edit_text() 503 | self.draw_body() 504 | 505 | def build_headstr(self, header: List[str]) -> urwid.Columns: 506 | """Builds TUI header according to parsed SQUEUE/SINFO header strings 507 | 508 | Parameters 509 | ---------- 510 | header: 511 | List of tokenized SQUEUE/SINFO strings 512 | 513 | Returns 514 | ------- 515 | headstr: 516 | Padded header strings organized in an `urwid.Columns` instance 517 | """ 518 | 519 | headstr = urwid.Columns( 520 | [ 521 | urwid.Padding(urwid.Text(h, align="left", wrap="clip"), right=2, left=2) 522 | for h in header 523 | ] 524 | ) 525 | return headstr 526 | 527 | def build_footstr(self) -> urwid.Columns: 528 | """Builds TUI footer, containg user/running job filter checkboxes, selected job counter, 529 | and search string entry. 530 | 531 | Returns 532 | ------- 533 | footstr: 534 | Footer widgets organized in an `urwid.Columns` instance. 535 | """ 536 | 537 | my_jobs = urwid.Padding( 538 | urwid.CheckBox( 539 | "My Jobs", state=True if self.my_jobs_first else False, has_mixed=False 540 | ), 541 | right=2, 542 | left=2, 543 | ) 544 | urwid.connect_signal( 545 | my_jobs.original_widget, "change", self.inspector.toggle_user_filter 546 | ) 547 | 548 | running_jobs = urwid.Padding( 549 | urwid.CheckBox("Running", state=False, has_mixed=False), right=2, left=2 550 | ) 551 | urwid.connect_signal( 552 | running_jobs.original_widget, "change", self.inspector.toggle_running_filter 553 | ) 554 | 555 | job_counter = urwid.Padding( 556 | urwid.Text(f"[{len(self.selected_jobs)}] Selected"), right=2, left=2 557 | ) 558 | 559 | name_search = urwid.Padding( 560 | urwid.Edit(caption="Search: ", edit_text="", wrap="any"), right=2, left=2 561 | ) 562 | 563 | urwid.connect_signal( 564 | name_search.original_widget, 565 | "postchange", 566 | self.update_filter_str, 567 | user_args=[name_search.original_widget], 568 | ) 569 | 570 | footstr = urwid.Columns([my_jobs, running_jobs, job_counter, name_search]) 571 | return footstr 572 | 573 | def build_squeue_list(self) -> List[SelectableColumns]: 574 | """Builds SQUEUE job list according to current filters 575 | 576 | Returns 577 | ------- 578 | jstrs: 579 | List of `SelectableColumns` instances, each one imbued with 580 | a specific SLURM job ID and colored according to its selection 581 | status. 582 | """ 583 | 584 | jstrs = [] 585 | for j in self.inspector.jobs: 586 | if any( 587 | [self.filter_str in j.attrs[h] for h in self.inspector.squeue_header] 588 | ): 589 | col = urwid.AttrMap( 590 | SelectableColumns( 591 | [ 592 | urwid.Padding( 593 | urwid.Text(j.attrs[h], align="left", wrap="clip"), 594 | right=2, 595 | left=2, 596 | ) 597 | for h in self.inspector.squeue_header 598 | ], 599 | JOBID=j.attrs["JOBID"], 600 | ), 601 | attr_map=( 602 | "selected" 603 | if j.attrs["JOBID"] in self.selected_jobs 604 | else "standard" 605 | ), 606 | focus_map="focus", 607 | ) 608 | urwid.connect_signal( 609 | col.original_widget, "click", self._toggle_selected, user_args=[col] 610 | ) 611 | jstrs.append(col) 612 | return jstrs 613 | 614 | def build_sinfo_list(self) -> List[urwid.Columns]: 615 | """Builds SINFO output list. 616 | 617 | Returns 618 | ------- 619 | strs: 620 | List of `urwid.Columns`, each one containing 621 | an output row from SINFO. 622 | """ 623 | 624 | istrs = [] 625 | for i in self.inspector.sinfo: 626 | col = urwid.AttrMap( 627 | urwid.Columns( 628 | [ 629 | urwid.Padding( 630 | urwid.Text(i[h], align="left", wrap="clip"), right=2, left=2 631 | ) 632 | for h in self.inspector.sinfo_header 633 | ], 634 | ), 635 | attr_map="standard", 636 | focus_map="standard", 637 | ) 638 | istrs.append(col) 639 | return istrs 640 | 641 | def draw_header(self): 642 | """Draws current TUI header""" 643 | if self.view == "squeue": 644 | headstr = self.build_headstr(self.inspector.squeue_header) 645 | if self.view == "sinfo": 646 | headstr = self.build_headstr(self.inspector.sinfo_header) 647 | self.top.header = urwid.AttrMap( 648 | urwid.LineBox(headstr, title="SlurmVision", **Tui._box_style_kwargs), 649 | "header", 650 | None, 651 | ) 652 | 653 | def draw_body(self): 654 | """Draws current TUI body""" 655 | if self.view == "squeue": 656 | strs = self.build_squeue_list() 657 | if self.view == "sinfo": 658 | strs = self.build_sinfo_list() 659 | if len(strs) == 0: 660 | self.top.body.original_widget.original_widget.body.clear() 661 | else: 662 | original_focus = self.top.body.original_widget.original_widget.body.focus 663 | if original_focus == None: 664 | original_focus = 0 665 | self.top.body.original_widget.original_widget.body.clear() 666 | self.top.body.original_widget.original_widget.body.extend(strs) 667 | self.top.body.original_widget.original_widget.body.set_focus( 668 | original_focus % len(strs) 669 | ) 670 | 671 | def update_top(self, *args): 672 | """Updates the TUI and sets an urwid loop alarm to re-draw the TUI 673 | according to the inspector's polling interval 674 | """ 675 | self.draw_body() 676 | self.handle = self.loop.set_alarm_in( 677 | self.inspector.polling_interval, self.update_top 678 | ) 679 | 680 | def _toggle_selected(self, col: SelectableColumns, *args): 681 | """Toggles the job under the cursor to be added/removed 682 | from the set of selected jobs. Selected jobs are colored. The cursor 683 | is automatically advanced to the next item to allow for fast selection 684 | of contiguous jobs. 685 | 686 | Parameters 687 | ---------- 688 | col: 689 | `SelectableColumn` instance identifying the job to be 690 | selected/deselected 691 | """ 692 | 693 | if col.original_widget.job_id not in self.selected_jobs: 694 | self.selected_jobs.add(col.original_widget.job_id) 695 | self.top.footer.original_widget.original_widget[2].set_text( 696 | f"[{len(self.selected_jobs)}] Selected" 697 | ) 698 | col.set_attr_map({None: "selected"}) 699 | if self.select_advance: 700 | original_focus = ( 701 | self.top.body.original_widget.original_widget.body.focus 702 | ) 703 | self.top.body.original_widget.original_widget.set_focus( 704 | (original_focus + 1) 705 | % len(self.top.body.original_widget.original_widget.body) 706 | ) 707 | else: 708 | self.selected_jobs.remove(col.original_widget.job_id) 709 | self.top.footer.original_widget.original_widget[2].set_text( 710 | f"[{len(self.selected_jobs)}] Selected" 711 | ) 712 | col.set_attr_map({None: None}) 713 | if self.select_advance: 714 | original_focus = ( 715 | self.top.body.original_widget.original_widget.body.focus 716 | ) 717 | self.top.body.original_widget.original_widget.set_focus( 718 | (original_focus + 1) 719 | % len(self.top.body.original_widget.original_widget.body) 720 | ) 721 | --------------------------------------------------------------------------------