Repository: kabouzeid/turm Branch: main Commit: 80ce7cf9bd9a Files: 17 Total size: 92.9 KB Directory structure: gitextract_6kiytyky/ ├── .github/ │ └── workflows/ │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ └── launch.json ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── scripts/ │ └── mock-slurm/ │ ├── bin/ │ │ ├── scancel │ │ ├── scontrol │ │ └── squeue │ └── logs/ │ └── .gitkeep └── src/ ├── app.rs ├── file_watcher.rs ├── job_watcher.rs ├── main.rs └── squeue_args.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: [main] env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always RUST_BACKTRACE: short RUSTUP_MAX_RETRIES: 10 jobs: # Update release PR release_please: name: Release Please runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} permissions: contents: write pull-requests: write steps: - uses: googleapis/release-please-action@v4 id: release with: release-type: rust # Build sources for every OS github_build: name: Build release binaries needs: release_please if: ${{ needs.release_please.outputs.release_created == 'true' }} strategy: matrix: include: - target: x86_64-unknown-linux-gnu os: ubuntu-latest name: turm-x86_64-unknown-linux-gnu.tar.gz - target: x86_64-unknown-linux-musl os: ubuntu-latest name: turm-x86_64-unknown-linux-musl.tar.gz - target: i686-unknown-linux-musl os: ubuntu-latest name: turm-i686-unknown-linux-musl.tar.gz - target: aarch64-unknown-linux-gnu os: ubuntu-latest name: turm-aarch64-unknown-linux-gnu.tar.gz - target: aarch64-unknown-linux-musl os: ubuntu-latest name: turm-aarch64-unknown-linux-musl.tar.gz - target: arm-unknown-linux-musleabihf os: ubuntu-latest name: turm-arm-unknown-linux-musleabihf.tar.gz runs-on: ${{ matrix.os }} steps: - name: Setup | Checkout uses: actions/checkout@v5 - name: Setup | Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true profile: minimal target: ${{ matrix.target }} - name: Build | Build uses: actions-rs/cargo@v1 with: command: build args: --release --locked --target ${{ matrix.target }} use-cross: ${{ matrix.os == 'ubuntu-latest' }} - name: Post Build | Prepare artifacts run: | cd target/${{ matrix.target }}/release tar czvf ../../../${{ matrix.name }} turm cd - - name: Release | Upload artifacts uses: actions/upload-artifact@v4 with: name: ${{ matrix.name }} path: ${{ matrix.name }} # Create GitHub release with Rust build targets and release notes upload_artifacts: name: Add Build Artifacts to Release needs: [release_please, github_build] runs-on: ubuntu-latest permissions: contents: write steps: - name: Setup | Artifacts uses: actions/download-artifact@v4 - name: Setup | Checksums run: for file in turm-*/turm-*; do openssl dgst -sha256 -r "$file" | awk '{print $1}' > "${file}.sha256"; done - name: Build | Add Artifacts to Release uses: softprops/action-gh-release@v2 with: files: turm-*/turm-* tag_name: ${{ needs.release_please.outputs.tag_name }} maturin_linux: name: Build Maturin Linux needs: release_please if: ${{ needs.release_please.outputs.release_created == 'true' }} runs-on: ${{ matrix.platform.runner }} strategy: matrix: platform: - runner: ubuntu-latest target: x86_64 - runner: ubuntu-latest target: x86 - runner: ubuntu-latest target: aarch64 - runner: ubuntu-latest target: armv7 - runner: ubuntu-latest target: s390x - runner: ubuntu-latest target: ppc64le steps: - uses: actions/checkout@v5 - name: Build wheels uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} args: --release --out dist sccache: 'true' manylinux: auto - name: Upload wheels uses: actions/upload-artifact@v4 with: name: wheels-linux-${{ matrix.platform.target }} path: dist maturin_musllinux: name: Build Maturin Linux musl needs: release_please if: ${{ needs.release_please.outputs.release_created == 'true' }} runs-on: ${{ matrix.platform.runner }} strategy: matrix: platform: - runner: ubuntu-latest target: x86_64 - runner: ubuntu-latest target: x86 - runner: ubuntu-latest target: aarch64 - runner: ubuntu-latest target: armv7 steps: - uses: actions/checkout@v5 - name: Build wheels uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} args: --release --out dist sccache: 'true' manylinux: musllinux_1_2 - name: Upload wheels uses: actions/upload-artifact@v4 with: name: wheels-musllinux-${{ matrix.platform.target }} path: dist maturin_release: name: Maturin | Release runs-on: ubuntu-latest needs: [upload_artifacts, maturin_linux, maturin_musllinux] # only publish if everything else worked steps: - uses: actions/download-artifact@v4 - name: Publish to PyPI uses: PyO3/maturin-action@v1 env: MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} with: command: upload args: --non-interactive --skip-existing wheels-*/* cargo_publish: name: Publish to crates.io needs: [upload_artifacts, maturin_release] # only publish if everything else worked runs-on: ubuntu-latest env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} steps: - uses: actions/checkout@v5 - name: Publish run: cargo publish ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: [ "main" ] pull_request: branches: [ "main" ] env: CARGO_TERM_COLOR: always jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Build run: cargo build --verbose - name: Run tests run: cargo test --verbose ================================================ FILE: .gitignore ================================================ /target scripts/mock-slurm/logs/* !scripts/mock-slurm/logs/.gitkeep ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "lldb", "request": "launch", "name": "Debug executable 'turm'", "cargo": { "args": [ "build", "--bin=turm", "--package=turm" ], "filter": { "name": "turm", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}", "env": { "RUST_BACKTRACE": "1" } }, { "type": "lldb", "request": "launch", "name": "Debug unit tests in executable 'turm'", "cargo": { "args": [ "test", "--no-run", "--bin=turm", "--package=turm" ], "filter": { "name": "turm", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}", "env": { "RUST_BACKTRACE": "1" } } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [0.14.0](https://github.com/karimknaebel/turm/compare/v0.13.1...v0.14.0) (2026-03-07) ### Features * add edit time limit dialog ([e376f89](https://github.com/karimknaebel/turm/commit/e376f89014a47d8362a4a6ad15a38cecaaa029ef)) * add signal picker for cancel action ([9562037](https://github.com/karimknaebel/turm/commit/9562037b72749a4c753de41d6d4dc0c1e49f52d9)) * change signal list style ([cd7fa24](https://github.com/karimknaebel/turm/commit/cd7fa241752a02016648dad1c58daf854cdd930f)) * show command errors in dialog (scancel, scontrol, ...) ([de4aed5](https://github.com/karimknaebel/turm/commit/de4aed57ddc0042faea4d9ecdddc8a9bc050e45c)) ## [0.13.1](https://github.com/karimknaebel/turm/compare/v0.13.0...v0.13.1) (2026-03-03) ### Bug Fixes * use bracketed paste mode to prevent accidental inputs ([19967cb](https://github.com/karimknaebel/turm/commit/19967cb2fcfedf8197ed94f86f815db1557818b5)) ## [0.13.0](https://github.com/karimknaebel/turm/compare/v0.12.0...v0.13.0) (2026-02-14) ### Features * add mouse support ([85bb9eb](https://github.com/karimknaebel/turm/commit/85bb9eb710a98281631a4e67ba11bd1f89e3abbd)) ## [0.12.0](https://github.com/karimknaebel/turm/compare/v0.11.0...v0.12.0) (2026-02-10) ### Features * add job name to detail window ([#42](https://github.com/karimknaebel/turm/issues/42)) ([0e40ab4](https://github.com/karimknaebel/turm/commit/0e40ab486bea2630f1717cc31e6380446ff0b87f)) ## [0.11.0](https://github.com/karimknaebel/turm/compare/v0.10.0...v0.11.0) (2025-12-23) ### Features * add g and G bindings to jump to first/last job ([2b92a8e](https://github.com/karimknaebel/turm/commit/2b92a8edb426dbec2c52e26127d8516a2e116dd2)) * scroll job list with ctrl+d/u ([3fe52ea](https://github.com/karimknaebel/turm/commit/3fe52eacc9b8745456e382281b1c58c1d21792e2)) ### Bug Fixes * clean up jobs refresh ([cc7b29b](https://github.com/karimknaebel/turm/commit/cc7b29b2f17c13b182c2171911da756c5bfc549b)) * clippy ([8cc6246](https://github.com/karimknaebel/turm/commit/8cc624612948bc0e57a5a14d73ac0f78c6040bdf)) * clippy ([6644393](https://github.com/karimknaebel/turm/commit/6644393ae45c50438e6315e6efd5676ad0680786)) * format ([bb28b2b](https://github.com/karimknaebel/turm/commit/bb28b2be8df5e53803a16847c1ce9c59f4458399)) * job list height ([9fdd3d7](https://github.com/karimknaebel/turm/commit/9fdd3d736d5ef65a72bf33da0103e952303dd574)) * preserve job selection across refresh ([e54ac01](https://github.com/karimknaebel/turm/commit/e54ac016bd844b545d53bccf1480a38214a690ed)) * remove unnecessary job_list_state modification in ui func ([50a780c](https://github.com/karimknaebel/turm/commit/50a780c967b41459dcb81702a2c28c0065e4d44d)) * reset terminal on panic ([bc1de57](https://github.com/karimknaebel/turm/commit/bc1de573353ab9cc19d123da1565b79e747da1c3)), closes [#52](https://github.com/karimknaebel/turm/issues/52) * revert calling scancel inside of thread ([741198f](https://github.com/karimknaebel/turm/commit/741198f5a10724b74e7f094743ae4d7a4ee1abed)) * wait for scancel to finish ([8b11875](https://github.com/karimknaebel/turm/commit/8b1187543997c9b03e88a92d6183a0eb7f3b2edd)) ## [0.10.0](https://github.com/karimknaebel/turm/compare/v0.9.0...v0.10.0) (2025-12-12) ### Features * rounded corners ([a689259](https://github.com/karimknaebel/turm/commit/a6892592723c50d2d7ce48b9379b497a573c3a68)) ## [0.9.0](https://github.com/karimknaebel/turm/compare/v0.8.0...v0.9.0) (2025-08-26) ### Features * auto-select first job on start ([c39613c](https://github.com/karimknaebel/turm/commit/c39613c32ca18625807a56081655f14533d63d32)) * show estimated start time for pending jobs ([6ce5f01](https://github.com/karimknaebel/turm/commit/6ce5f01730d400f1a9be0d97dd17100728ce72d0)) ### Bug Fixes * cargo warning ([8979926](https://github.com/karimknaebel/turm/commit/8979926149d07b7e78e2839d767679efb4d52c2b)) ## [0.8.0](https://github.com/karimknaebel/turm/compare/v0.7.3...v0.8.0) (2025-08-24) ### Features * auto-refresh non-existing file paths until they are created ([3a3acc6](https://github.com/karimknaebel/turm/commit/3a3acc6480faf58dc93de31d031bd2a90db117e8)) ## [0.7.3](https://github.com/karimknaebel/turm/compare/v0.7.2...v0.7.3) (2024-07-28) ### Miscellaneous Chores * release 0.7.3 ([ae8665b](https://github.com/karimknaebel/turm/commit/ae8665b25d68842dc1100f85aee643bc122ef52f)) ## [0.7.2](https://github.com/karimknaebel/turm/compare/v0.7.1...v0.7.2) (2024-07-28) ### Bug Fixes * crash on resize ([96f4f16](https://github.com/karimknaebel/turm/commit/96f4f1683ee98547dadc610cf21f293858ba9d50)) ## [0.7.1](https://github.com/karimknaebel/turm/compare/v0.6.0...v0.7.1) (2024-07-28) ### Features * pretty text wrapping ([51dc964](https://github.com/karimknaebel/turm/commit/51dc9645f506b89a0444db64cab6ddc0d2ecdaf0)) * toggle log text wrapping. update deps ([5243a36](https://github.com/karimknaebel/turm/commit/5243a368c173070c58ce8a51bce56be9f916ec21)) * truncated line indicator ([f347664](https://github.com/karimknaebel/turm/commit/f347664ecd94db785140eac296e37d66e203a81b)) ### Bug Fixes * correctly resolve relative log file paths ([0ecc902](https://github.com/karimknaebel/turm/commit/0ecc902f036244ed67d29eb686dcbf2c413ec51c)) * crash on resize ([6dc3b1d](https://github.com/karimknaebel/turm/commit/6dc3b1d9f387d3b2accdc34b7e8a0c42995424c9)) ### Miscellaneous Chores * release 0.7.1 ([499a3a6](https://github.com/karimknaebel/turm/commit/499a3a69059adab68444d552acc4838962db4e0b)) ## [0.6.0](https://github.com/karimknaebel/turm/compare/v0.5.0...v0.6.0) (2023-09-23) ### Features * toggle stdout/stderr ([bcd773b](https://github.com/karimknaebel/turm/commit/bcd773bd21ccb64860e651e2da881d57253fecb8)) ## [0.5.0](https://github.com/karimknaebel/turm/compare/v0.4.0...v0.5.0) (2023-09-15) ### Features * show job count ([c169e18](https://github.com/karimknaebel/turm/commit/c169e1844574885246736dbde920ae0f77b121b2)) ## [0.4.0](https://github.com/karimknaebel/turm/compare/v0.3.0...v0.4.0) (2023-04-23) ### Features * faster fast scrolling (shift/control/alt) ([37e205a](https://github.com/karimknaebel/turm/commit/37e205aaf819e99e13aea70327de84289cba0482)) * scroll to top/bottom ([0022a70](https://github.com/karimknaebel/turm/commit/0022a70a58d6a0f2b1e159f0b5afef99ae6ea2c1)) ## [0.3.0](https://github.com/karimknaebel/turm/compare/v0.2.0...v0.3.0) (2023-04-17) ### Features * add shell completions ([e9b8de0](https://github.com/karimknaebel/turm/commit/e9b8de0018b3dd91d72db6e3c164aa18a1fe17d9)) * proper cli with help ([90988f6](https://github.com/karimknaebel/turm/commit/90988f65208b353204acd6a570be45e753bfcdfc)) ## [0.2.0](https://github.com/karimknaebel/turm/compare/v0.1.0...v0.2.0) (2023-04-15) ### Features * cancel jobs ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b)) * select first job on launch ([7c742fd](https://github.com/karimknaebel/turm/commit/7c742fdd3b66787b10df6a017de6c7522c8f9858)) ### Bug Fixes * clear the log on empty selection ([518afdb](https://github.com/karimknaebel/turm/commit/518afdbf67ada9ea1d7b2597765630cba8a00ee4)) * correctly display job ids in arrays ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b)) ## 0.1.0 (2023-03-31) ### Features * accept same cli args as `squeue` ([1f1a5ac](https://github.com/karimknaebel/turm/commit/1f1a5ac8f0b92b435b09e09981c95cbb00290a20)) * add cargo metadata ([78487bb](https://github.com/karimknaebel/turm/commit/78487bbe93c8c1efaef8b218e72c68a4dbe3c67a)) * better error handling ([ad47d19](https://github.com/karimknaebel/turm/commit/ad47d19ad6abccb80bc7d5c9ac3faf44ca03a92a)) * better layout ([67e24e0](https://github.com/karimknaebel/turm/commit/67e24e078df0eed492123e498282942400cbbcf9)) * config interval file ([7e6678d](https://github.com/karimknaebel/turm/commit/7e6678d834ce5535dfe2ede8e88974ccbf36c453)) * fast scroll ([8df9158](https://github.com/karimknaebel/turm/commit/8df91589f8ef6c3cd403faecfc40142fd238d0a4)) * faster log file loading ([9f954cc](https://github.com/karimknaebel/turm/commit/9f954ccff53fc7ffdb4412d1a490ef012bf4cc95)) * faster log loading ([b4b0fa4](https://github.com/karimknaebel/turm/commit/b4b0fa4df97d51976f2cadffd527a07fd3804346)) * help bar ([ab63a9e](https://github.com/karimknaebel/turm/commit/ab63a9e2cd9b2ea05a8d45789b8dfb04d580c932)) * partial reads (like tail -f) ([86f04af](https://github.com/karimknaebel/turm/commit/86f04af1bf78783c37c4cecbef4d3292280f4f5e)) * prettier ([c70de5e](https://github.com/karimknaebel/turm/commit/c70de5ea4f412531c203bb308ee769e6cc861828)) * scroll to bottom with end ([01423f1](https://github.com/karimknaebel/turm/commit/01423f1a8c5da16f97dc01efd4e73cbb96d8c810)) * show job details ([904ff7c](https://github.com/karimknaebel/turm/commit/904ff7cef52e8971f7c6146ec217065988001336)) * show state and reason in details panel ([a77d4a3](https://github.com/karimknaebel/turm/commit/a77d4a3ff7d823f89ea33921dee28aa9ff7b6a3f)) * show state in list ([823a0a2](https://github.com/karimknaebel/turm/commit/823a0a263bc33b7a1e77d92601820059dfc22a14)) ### Bug Fixes * error on shutdown ([ff516ca](https://github.com/karimknaebel/turm/commit/ff516cac734fcd06a443122aca408d228046484a)) * hide incomplete lines in log files ([28eb452](https://github.com/karimknaebel/turm/commit/28eb452f9b4e8900d74be491368787bbe2197fc1)) * log title ([d42f79a](https://github.com/karimknaebel/turm/commit/d42f79ae7dcfec4d33d29fdcc48e1e986d1ea8b9)) * warnings ([ffd1211](https://github.com/karimknaebel/turm/commit/ffd1211228490960186d9cf8dc1d773a38558b16)) ================================================ FILE: Cargo.toml ================================================ [package] name = "turm" version = "0.14.0" authors = ["Karim Knaebel "] description = "A TUI for the Slurm Workload Manager." repository = "https://github.com/karimknaebel/turm" license = "MIT" edition = "2024" rust-version = "1.87" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] clap = { version = "4.5.40", features = ["derive"] } clap_complete = "4.5.54" crossbeam = "0.8.4" crossterm = "0.29.0" itertools = "0.14.0" lazy_static = "1.5.0" notify = "8.0.0" ratatui = "0.30.0" regex = "1.11.1" tui-input = { version = "0.14.0", default-features = false, features = ["crossterm"] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 Karim Knaebel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # turm [![image](https://img.shields.io/pypi/v/turm.svg)](https://pypi.python.org/pypi/turm) [![image](https://img.shields.io/crates/v/turm.svg)](https://crates.io/crates/turm) [![Conda Version](https://img.shields.io/conda/vn/conda-forge/turm.svg)](https://anaconda.org/conda-forge/turm) A TUI for [Slurm](https://slurm.schedmd.com/), which provides a convenient way to manage your cluster jobs. turm demo `turm` accepts the same options as `squeue` (see [man squeue](https://slurm.schedmd.com/squeue.html#SECTION_OPTIONS)). Use `turm --help` to get a list of all available options. For example, to show only your own jobs, sorted by descending job ID, including all job states (i.e., including completed and failed jobs): ```shell turm --me --sort=-id --states=ALL ``` ## Installation `turm` is available on [PyPI](https://pypi.org/project/turm/), [crates.io](https://crates.io/crates/turm), and [conda-forge](https://github.com/conda-forge/turm-feedstock): ```shell # With uv. uv tool install turm # With pip. pip install turm # With cargo. cargo install turm # With pixi. pixi global install turm # With conda. conda install --channel conda-forge turm # With wget. Make sure ~/.local/bin is in your $PATH. wget https://github.com/karimknaebel/turm/releases/latest/download/turm-x86_64-unknown-linux-musl.tar.gz -O - | tar -xz -C ~/.local/bin/ ``` The [release page](https://github.com/karimknaebel/turm/releases) also contains precompiled binaries for Linux. ### Shell Completion (optional) #### Bash In your `.bashrc`, add the following line: ```bash eval "$(turm completion bash)" ``` #### Zsh In your `.zshrc`, add the following line: ```zsh eval "$(turm completion zsh)" ``` #### Fish In your `config.fish` or in a separate `completions/turm.fish` file, add the following line: ```fish turm completion fish | source ``` ## How it works `turm` obtains information about jobs by parsing the output of `squeue`. The reason for this is that `squeue` is available on all Slurm clusters, and running it periodically is not too expensive for the Slurm controller ( particularly when [filtering by user](https://slurm.schedmd.com/squeue.html#OPT_user)). In contrast, Slurm's C API is unstable, and Slurm's REST API is not always available and can be costly for the Slurm controller. Another advantage is that we get free support for the exact same CLI flags as `squeue`, which users are already familiar with, for filtering and sorting the jobs. ### Resource usage TL;DR: `turm` ≈ `watch -n2 squeue` + `tail -f slurm-log.out` Special care has been taken to ensure that `turm` is as lightweight as possible in terms of its impact on the Slurm controller and its file I/O operations. The job queue is updated every two seconds by running `squeue`. When there are many jobs in the queue, it is advisable to specify a single user to reduce the load on the Slurm controller (see [squeue --user](https://slurm.schedmd.com/squeue.html#OPT_user)). `turm` updates the currently displayed log file on every inotify modify notification, and it only reads the newly appended lines after the initial read. However, since inotify notifications are not supported for remote file systems, such as NFS, `turm` also polls the file for newly appended bytes every two seconds. ## Development without Slurm For local UI testing, this repository includes mocks for `squeue`, `scancel`, and `scontrol`: ```shell PATH=scripts/mock-slurm/bin:$PATH cargo run -- --me ``` The mock commands read/write files in `scripts/mock-slurm/logs`, so you can test log rendering and control actions without a Slurm install. ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=karimknaebel/turm&type=Date)](https://www.star-history.com/#karimknaebel/turm&Date) ================================================ FILE: scripts/mock-slurm/bin/scancel ================================================ #!/usr/bin/env bash set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" root_dir="$(cd -- "$script_dir/../../.." && pwd)" log_dir="$root_dir/scripts/mock-slurm/logs" log_file="$log_dir/scancel.log" mkdir -p "$log_dir" timestamp="$(date '+%Y-%m-%d %H:%M:%S')" job_id="unknown" signal="" while (($#)); do case "$1" in --signal|-s) signal="${2:-}" shift 2 ;; --signal=*) signal="${1#*=}" shift ;; --*) shift ;; *) job_id="$1" shift ;; esac done if [[ -n "$signal" ]]; then printf '[%s] canceled %s with signal %s\n' "$timestamp" "$job_id" "$signal" >>"$log_file" else printf '[%s] canceled %s\n' "$timestamp" "$job_id" >>"$log_file" fi ================================================ FILE: scripts/mock-slurm/bin/scontrol ================================================ #!/usr/bin/env bash set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" root_dir="$(cd -- "$script_dir/../../.." && pwd)" log_dir="$root_dir/scripts/mock-slurm/logs" log_file="$log_dir/scontrol.log" mkdir -p "$log_dir" timestamp="$(date '+%Y-%m-%d %H:%M:%S')" job_id="" time_limit="" if [[ "${1:-}" == "update" ]]; then shift fi for arg in "$@"; do case "$arg" in JobId=*) job_id="${arg#JobId=}" ;; TimeLimit=*) time_limit="${arg#TimeLimit=}" ;; esac done if [[ -n "$job_id" && -n "$time_limit" ]]; then printf '[%s] updated %s TimeLimit=%s\n' "$timestamp" "$job_id" "$time_limit" >>"$log_file" exit 0 fi printf '[%s] invalid scontrol call: %s\n' "$timestamp" "$*" >>"$log_file" exit 1 ================================================ FILE: scripts/mock-slurm/bin/squeue ================================================ #!/usr/bin/env bash set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" root_dir="$(cd -- "$script_dir/../../.." && pwd)" log_dir="$root_dir/scripts/mock-slurm/logs" mkdir -p "$log_dir" stdout_running="$log_dir/job-1001.out" stderr_running="$log_dir/job-1001.err" stdout_pending="$log_dir/job-1002.out" stderr_pending="$log_dir/job-1002.err" if [ ! -f "$stdout_running" ]; then cat >"$stdout_running" <<'EOF' Starting training loop... Epoch 1/50 - loss=1.8421 Epoch 2/50 - loss=1.5033 EOF fi if [ ! -f "$stderr_running" ]; then : >"$stderr_running" fi sep='###turm###' printf '%s\n' \ "1001${sep}train-model${sep}RUNNING${sep}${USER:-mock}${sep}00:12:37${sep}01:00:00${sep}N/A${sep}cpu=4,mem=8G${sep}debug${sep}mac-mini-01${sep}${stdout_running}${sep}${stderr_running}${sep}python train.py --epochs 50${sep}R${sep}None${sep}1001${sep}N/A${sep}mac-mini-01${sep}${root_dir}${sep}" \ "1002${sep}eval-suite${sep}PENDING${sep}${USER:-mock}${sep}0:00${sep}00:30:00${sep}2026-03-04T12:00:00${sep}cpu=2,mem=4G${sep}debug${sep}(null)${sep}${stdout_pending}${sep}${stderr_pending}${sep}python eval.py --dataset validation${sep}PD${sep}Resources${sep}1002${sep}N/A${sep}(null)${sep}${root_dir}${sep}" ================================================ FILE: scripts/mock-slurm/logs/.gitkeep ================================================ ================================================ FILE: src/app.rs ================================================ use crossbeam::{ channel::{Receiver, TryRecvError, unbounded}, select, }; use itertools::Either; use std::{cmp::min, iter::once, path::PathBuf, process::Command, time::Duration}; use crate::file_watcher::{FileWatcherError, FileWatcherHandle}; use crate::job_watcher::JobWatcherHandle; use crossterm::event::{Event, KeyCode, KeyEvent, MouseButton, MouseEventKind}; use ratatui::{ Frame, Terminal, backend::Backend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; use std::io; use tui_input::{Input, backend::crossterm::EventHandler}; pub enum Focus { Jobs, } pub enum Dialog { ConfirmCancelJob(String), SelectCancelSignal { id: String, selected_signal: usize }, EditTimeLimit { id: String, input: Input }, CommandError { command: String, output: String }, } struct CommandFailure { command: String, output: String, } #[derive(Clone, Copy)] pub enum ScrollAnchor { Top, Bottom, } #[derive(Default)] pub enum OutputFileView { #[default] Stdout, Stderr, } pub struct App { focus: Focus, dialog: Option, jobs: Vec, job_list_state: ListState, job_output: Result, job_output_anchor: ScrollAnchor, job_output_offset: u16, job_output_wrap: bool, _job_watcher: JobWatcherHandle, job_output_watcher: FileWatcherHandle, // sender: Sender, receiver: Receiver, input_receiver: Receiver>, output_file_view: OutputFileView, job_list_height: u16, job_list_area: Rect, job_output_area: Rect, pending_input_event: Option, } pub struct Job { pub job_id: String, pub array_id: String, pub array_step: Option, pub name: String, pub state: String, pub state_compact: String, pub reason: Option, pub user: String, pub time: String, pub time_limit: String, pub start_time: String, pub tres: String, pub partition: String, pub nodelist: String, pub stdout: Option, pub stderr: Option, pub command: String, } impl Job { fn id(&self) -> String { match self.array_step.as_ref() { Some(array_step) => format!("{}_{}", self.array_id, array_step), None => self.job_id.clone(), } } } pub enum AppMessage { Jobs(Vec), JobOutput(Result), Key(KeyEvent), MouseClick(usize), MouseWheel { target: MouseScrollTarget, direction: MouseWheelDirection, amount: u16, }, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum MouseWheelDirection { Up, Down, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum MouseScrollTarget { Jobs, Output, } const SCANCEL_SIGNALS: &[&str] = &["TERM", "INT", "HUP", "USR1", "USR2", "STOP", "CONT", "KILL"]; const DIALOG_WIDTH: u16 = 80; impl App { pub fn new( input_receiver: Receiver>, slurm_refresh_rate: u64, file_refresh_rate: u64, squeue_args: Vec, ) -> App { let (sender, receiver) = unbounded(); Self { focus: Focus::Jobs, dialog: None, jobs: Vec::new(), _job_watcher: JobWatcherHandle::new( sender.clone(), Duration::from_secs(slurm_refresh_rate), squeue_args, ), job_list_state: ListState::default(), job_output: Ok("".to_string()), job_output_anchor: ScrollAnchor::Bottom, job_output_offset: 0, job_output_wrap: false, job_output_watcher: FileWatcherHandle::new( sender.clone(), Duration::from_secs(file_refresh_rate), ), // sender, receiver, input_receiver, output_file_view: OutputFileView::default(), job_list_height: 0, job_list_area: Rect::default(), job_output_area: Rect::default(), pending_input_event: None, } } } impl App { pub fn run>( &mut self, terminal: &mut Terminal, ) -> io::Result<()> { terminal.draw(|f| self.ui(f))?; loop { let (should_quit, should_draw) = if let Some(event) = self.pending_input_event.take() { self.handle_input_event(event) } else { select! { recv(self.receiver) -> event => { self.handle(event.unwrap()); (false, true) } recv(self.input_receiver) -> input_res => { self.handle_input_event(input_res.unwrap().unwrap()) } } }; if should_quit { return Ok(()); } if should_draw { terminal.draw(|f| self.ui(f))?; } } } fn try_recv_input_event(&mut self) -> Option { if let Some(event) = self.pending_input_event.take() { return Some(event); } loop { match self.input_receiver.try_recv() { Ok(Ok(event)) => return Some(event), Ok(Err(_)) => continue, Err(TryRecvError::Empty | TryRecvError::Disconnected) => return None, } } } fn handle_input_event(&mut self, event: Event) -> (bool, bool) { match event { Event::Key(key) => { if key.code == KeyCode::Char('q') { return (true, false); } self.handle(AppMessage::Key(key)); (false, true) } Event::Paste(_) => (false, false), Event::Mouse(mouse) => match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { if self.dialog.is_some() { return (false, false); } if let Some(index) = self.job_index_at(mouse.column, mouse.row) { if self.job_list_state.selected() != Some(index) { self.handle(AppMessage::MouseClick(index)); return (false, true); } } (false, false) } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { if self.dialog.is_some() { return (false, false); } let Some(target) = self.mouse_scroll_target(mouse.column, mouse.row) else { return (false, false); }; let direction = mouse_wheel_direction(mouse.kind).unwrap(); let mut amount = 1u16; while let Some(next_event) = self.try_recv_input_event() { let should_merge = if let Event::Mouse(next_mouse) = &next_event { mouse_wheel_direction(next_mouse.kind) == Some(direction) && self.mouse_scroll_target(next_mouse.column, next_mouse.row) == Some(target) } else { false }; if should_merge { amount = amount.saturating_add(1); } else { self.pending_input_event = Some(next_event); break; } } self.handle(AppMessage::MouseWheel { target, direction, amount, }); (false, true) } _ => (false, false), }, Event::Resize(_, _) => (false, true), _ => (false, false), } } fn mouse_scroll_target(&self, column: u16, row: u16) -> Option { if rect_contains(self.job_list_area, column, row) { Some(MouseScrollTarget::Jobs) } else if rect_contains(self.job_output_area, column, row) { Some(MouseScrollTarget::Output) } else { None } } fn handle(&mut self, msg: AppMessage) { match msg { AppMessage::Jobs(jobs) => { // On refresh: keep the same job selected if it still exists let old_index = self.job_list_state.selected(); let old_id = old_index.and_then(|i| self.jobs.get(i)).map(|j| j.id()); self.jobs = jobs; if self.jobs.is_empty() { self.job_list_state.select(None); } else if let Some(id) = old_id { let new_index = self .jobs .iter() .position(|j| j.id() == id) .unwrap_or(old_index.unwrap_or(0).min(self.jobs.len() - 1)); self.job_list_state.select(Some(new_index)); } else { self.job_list_state.select_first(); } } AppMessage::JobOutput(content) => self.job_output = content, AppMessage::Key(key) => { if self.dialog.is_some() { let mut close_dialog = false; let mut scancel_request = None; let mut timelimit_request = None; let mut command_failure = None; match self.dialog.as_mut().expect("dialog must exist") { Dialog::ConfirmCancelJob(id) => match key.code { KeyCode::Enter | KeyCode::Char('y') => { scancel_request = Some((id.clone(), None)); close_dialog = true; } KeyCode::Esc => { close_dialog = true; } _ => {} }, Dialog::SelectCancelSignal { id, selected_signal, } => match key.code { KeyCode::Up | KeyCode::Char('k') => { *selected_signal = selected_signal.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { *selected_signal = min( selected_signal.saturating_add(1), SCANCEL_SIGNALS.len().saturating_sub(1), ); } KeyCode::Enter => { scancel_request = Some((id.clone(), Some(SCANCEL_SIGNALS[*selected_signal]))); close_dialog = true; } KeyCode::Esc => { close_dialog = true; } KeyCode::Char(c) if c.is_ascii_digit() => { if let Some(index) = signal_index_for_digit(c) { if index < SCANCEL_SIGNALS.len() { *selected_signal = index; } } } _ => {} }, Dialog::EditTimeLimit { id, input } => match key.code { KeyCode::Enter => { if let Some(time_limit) = validated_time_limit(input) { timelimit_request = Some((id.clone(), time_limit)); close_dialog = true; } } KeyCode::Esc => { close_dialog = true; } _ => { input.handle_event(&Event::Key(key)); } }, Dialog::CommandError { .. } => match key.code { KeyCode::Enter | KeyCode::Esc => { close_dialog = true; } _ => {} }, }; if let Some((id, signal)) = scancel_request { command_failure = execute_scancel(&id, signal).err(); } if let Some((id, time_limit)) = timelimit_request { command_failure = execute_scontrol_update_timelimit(&id, &time_limit).err(); } if let Some(CommandFailure { command, output }) = command_failure { self.dialog = Some(Dialog::CommandError { command, output }); } else if close_dialog { self.dialog = None; } } else { match key.code { KeyCode::Char('h') | KeyCode::Left => self.focus_previous_panel(), KeyCode::Char('l') | KeyCode::Right => self.focus_next_panel(), KeyCode::Char('k') | KeyCode::Up => match self.focus { Focus::Jobs => self.select_previous_job(), }, KeyCode::Char('j') | KeyCode::Down => match self.focus { Focus::Jobs => self.select_next_job(), }, KeyCode::Char('g') => match self.focus { Focus::Jobs => self.select_first_job(), }, KeyCode::Char('G') => match self.focus { Focus::Jobs => self.select_last_job(), }, KeyCode::Char('u') => match self.focus { Focus::Jobs => { if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) { self.scroll_jobs_half_page_up() } } }, KeyCode::Char('d') => match self.focus { Focus::Jobs => { if key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) { self.scroll_jobs_half_page_down() } } }, KeyCode::PageDown => { let delta = if key.modifiers.intersects( crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, ) { 50 } else { 1 }; self.scroll_job_output_down_by(delta); } KeyCode::PageUp => { let delta = if key.modifiers.intersects( crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, ) { 50 } else { 1 }; self.scroll_job_output_up_by(delta); } KeyCode::Home => { self.job_output_offset = 0; self.job_output_anchor = ScrollAnchor::Top; } KeyCode::End => { self.job_output_offset = 0; self.job_output_anchor = ScrollAnchor::Bottom; } KeyCode::Char('c') => { if let Some(id) = self.selected_job_id() { self.dialog = Some(Dialog::ConfirmCancelJob(id)); } } KeyCode::Char('C') => { if let Some(id) = self.selected_job_id() { self.dialog = Some(Dialog::SelectCancelSignal { id, selected_signal: 0, }); } } KeyCode::Char('t') => { if let Some(job) = self.selected_job() { self.dialog = Some(Dialog::EditTimeLimit { id: job.id(), input: Input::new(job.time_limit.clone()), }); } } KeyCode::Char('o') => { self.output_file_view = match self.output_file_view { OutputFileView::Stdout => OutputFileView::Stderr, OutputFileView::Stderr => OutputFileView::Stdout, }; } KeyCode::Char('w') => { self.job_output_wrap = !self.job_output_wrap; } _ => {} }; } } AppMessage::MouseClick(index) => { if self.dialog.is_none() && index < self.jobs.len() { self.job_list_state.select(Some(index)); } } AppMessage::MouseWheel { target, direction, amount, } => { if self.dialog.is_none() { match target { MouseScrollTarget::Jobs => match direction { MouseWheelDirection::Up => self.job_list_state.scroll_up_by(amount), MouseWheelDirection::Down => self.job_list_state.scroll_down_by(amount), }, MouseScrollTarget::Output => match direction { MouseWheelDirection::Up => self.scroll_job_output_up_by(amount), MouseWheelDirection::Down => self.scroll_job_output_down_by(amount), }, } } } } // update self.job_output_watcher .set_file_path(self.job_list_state.selected().and_then(|i| { self.jobs.get(i).and_then(|j| match self.output_file_view { OutputFileView::Stdout => j.stdout.clone(), OutputFileView::Stderr => j.stderr.clone(), }) })); } fn ui(&mut self, f: &mut Frame) { // Layout let content_help = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(1)].as_ref()) .split(f.area()); let master_detail = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(50), Constraint::Percentage(70)].as_ref()) .split(content_help[0]); let job_detail_log = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(8), Constraint::Min(3)].as_ref()) .split(master_detail[1]); // Help let help_options = vec![ ("q", "quit"), ("⏶/⏷", "navigate"), ("pgup/pgdown", "scroll"), ("home/end", "top/bottom"), ("esc", "cancel"), ("enter", "confirm"), ("c/C", "cancel/signal"), ("t", "set time limit"), ("o", "toggle stdout/stderr"), ("w", "toggle text wrap"), ]; let blue_style = Style::default().fg(Color::Blue); let light_blue_style = Style::default().fg(Color::LightBlue); let help = Line::from(help_options.iter().fold( Vec::new(), |mut acc, (key, description)| { if !acc.is_empty() { acc.push(Span::raw(" | ")); } acc.push(Span::styled(*key, blue_style)); acc.push(Span::raw(": ")); acc.push(Span::styled(*description, light_blue_style)); acc }, )); let help = Paragraph::new(help); f.render_widget(help, content_help[1]); // Jobs let max_id_len = self.jobs.iter().map(|j| j.id().len()).max().unwrap_or(0); let max_user_len = self.jobs.iter().map(|j| j.user.len()).max().unwrap_or(0); let max_partition_len = self .jobs .iter() .map(|j| j.partition.len()) .max() .unwrap_or(0); let max_time_len = self.jobs.iter().map(|j| j.time.len()).max().unwrap_or(0); let max_state_compact_len = self .jobs .iter() .map(|j| j.state_compact.len()) .max() .unwrap_or(0); let jobs: Vec = self .jobs .iter() .map(|j| { ListItem::new(Line::from(vec![ Span::styled( format!( "{:max$.max$}", j.time, max = max_time_len), Style::default().fg(Color::Red), ), Span::raw(" "), Span::raw(&j.name), ])) }) .collect(); let job_list = List::new(jobs) .block( Block::default() .title(format!("─Jobs ({})", self.jobs.len())) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(if self.dialog.is_some() { Style::default() } else { match self.focus { Focus::Jobs => Style::default().fg(Color::Green), } }), ) .highlight_style(Style::default().bg(Color::Green).fg(Color::Black)); f.render_stateful_widget(job_list, master_detail[0], &mut self.job_list_state); self.job_list_height = master_detail[0].height.saturating_sub(2); // account for borders self.job_list_area = master_detail[0]; // Job details let job_detail = self .job_list_state .selected() .and_then(|i| self.jobs.get(i)); let job_detail = job_detail.map(|j| { let mut state_spans = vec![ Span::styled("State ", Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw(&j.state), ]; if j.state == "PENDING" { state_spans.extend([ Span::styled(" Start ", Style::default().fg(Color::Yellow)), Span::raw(&j.start_time), ]); } if let Some(s) = j.reason.as_deref() { state_spans.extend([ Span::styled(" Reason ", Style::default().fg(Color::Yellow)), Span::raw(s), ]); } let state = Line::from(state_spans); let name = Line::from(vec![ Span::styled("Name ", Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw(&j.name), ]); let command = Line::from(vec![ Span::styled("Command", Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw(&j.command), ]); let nodes = Line::from(vec![ Span::styled("Nodes ", Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw(&j.nodelist), ]); let tres = Line::from(vec![ Span::styled("TRES ", Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw(&j.tres), ]); let ui_stdout_text = match self.output_file_view { OutputFileView::Stdout => "stdout ", OutputFileView::Stderr => "stderr ", }; let stdout = Line::from(vec![ Span::styled(ui_stdout_text, Style::default().fg(Color::Yellow)), Span::raw(" "), Span::raw( match self.output_file_view { OutputFileView::Stdout => &j.stdout, OutputFileView::Stderr => &j.stderr, } .as_ref() .map(|p| p.to_str().unwrap_or_default()) .unwrap_or_default(), ), ]); Text::from(vec![state, name, command, nodes, tres, stdout]) }); let job_detail = Paragraph::new(job_detail.unwrap_or_default()).block( Block::default() .title("─Details") .borders(Borders::ALL) .border_type(BorderType::Rounded), ); f.render_widget(job_detail, job_detail_log[0]); // Log let log_area = job_detail_log[1]; self.job_output_area = log_area; let log_title = Line::from(vec![ Span::raw("─"), Span::raw(match self.output_file_view { OutputFileView::Stdout => "stdout", OutputFileView::Stderr => "stderr", }), Span::styled( match self.job_output_anchor { ScrollAnchor::Top if self.job_output_offset == 0 => "[T]".to_string(), ScrollAnchor::Top => format!("[T+{}]", self.job_output_offset), ScrollAnchor::Bottom if self.job_output_offset == 0 => "".to_string(), ScrollAnchor::Bottom => format!("[B-{}]", self.job_output_offset), }, Style::default().add_modifier(Modifier::DIM), ), ]); let log_block = Block::default().title(log_title).borders(Borders::ALL); let log_block = log_block.border_type(BorderType::Rounded); // let job_log = self.job_stdout.as_deref().map(|s| { // string_for_paragraph( // s, // log_block.inner(log_area).height as usize, // log_block.inner(log_area).width as usize, // self.job_stdout_offset as usize, // ) // }).unwrap_or_else(|e| { // self.job_stdout_offset = 0; // "".to_string() // }); let log = match self.job_output.as_deref() { Ok(s) => Paragraph::new(fit_text( s, log_block.inner(log_area).height as usize, log_block.inner(log_area).width as usize, self.job_output_anchor, self.job_output_offset as usize, self.job_output_wrap, )), Err(e) => Paragraph::new(e.to_string()) .style(Style::default().fg(Color::Red)) .wrap(Wrap { trim: true }), } .block(log_block); f.render_widget(log, log_area); if let Some(dialog) = &self.dialog { fn centered_dialog_area(width: u16, lines: u16, viewport: Rect) -> Rect { let dialog_width = min(width, viewport.width); let dialog_height = min(lines, viewport.height); let dialog_x = viewport.x + viewport.width.saturating_sub(dialog_width) / 2; let dialog_y = viewport.y + viewport.height.saturating_sub(dialog_height) / 2; Rect::new(dialog_x, dialog_y, dialog_width, dialog_height) } match dialog { Dialog::ConfirmCancelJob(id) => { let dialog = Paragraph::new(Line::from(vec![ Span::raw("Cancel job "), Span::styled(id, Style::default().add_modifier(Modifier::BOLD)), Span::raw("?"), ])) .style(Style::default().fg(Color::White)) .wrap(Wrap { trim: true }) .block( Block::default() .title("─Cancel") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().fg(Color::Green)), ); let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area()); f.render_widget(Clear, area); f.render_widget(dialog, area); } Dialog::SelectCancelSignal { id, selected_signal, } => { let mut rows = vec![ Line::from(vec![ Span::raw("Send signal to job "), Span::styled(id, Style::default().add_modifier(Modifier::BOLD)), Span::raw(":"), ]), Line::default(), ]; rows.extend(SCANCEL_SIGNALS.iter().enumerate().map(|(i, signal)| { let signal_style = if i == *selected_signal { Style::default().fg(Color::Black).bg(Color::Green) } else { Style::default() }; let shortcut_style = signal_style.add_modifier(Modifier::DIM); Line::from(vec![ Span::styled(format!("{}. ", i + 1), shortcut_style), Span::styled(*signal, signal_style), ]) })); let dialog = Paragraph::new(Text::from(rows)) .style(Style::default().fg(Color::White)) .wrap(Wrap { trim: true }) .block( Block::default() .title("─Signal") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().fg(Color::Green)), ); let area = centered_dialog_area( DIALOG_WIDTH, SCANCEL_SIGNALS.len() as u16 + 4, f.area(), ); f.render_widget(Clear, area); f.render_widget(dialog, area); } Dialog::EditTimeLimit { id, input } => { let block = Block::default() .title("─Time Limit") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().fg(Color::Green)); let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area()); let inner = block.inner(area); let prompt_prefix = "Set time limit for job "; let prompt_suffix = ": "; let prompt_width = (prompt_prefix.chars().count() + id.chars().count() + prompt_suffix.chars().count()) as u16; let available_width = inner.width.saturating_sub(prompt_width).max(1) as usize; let scroll = input.visual_scroll(available_width); let visible_value = input .value() .chars() .skip(scroll) .take(available_width) .collect::(); let dialog = Paragraph::new(Line::from(vec![ Span::raw(prompt_prefix), Span::styled(id, Style::default().add_modifier(Modifier::BOLD)), Span::raw(prompt_suffix), Span::styled(visible_value, Style::default().fg(Color::Blue)), ])) .style(Style::default().fg(Color::White)) .block(block); f.render_widget(Clear, area); f.render_widget(dialog, area); let cursor_offset = input.visual_cursor().saturating_sub(scroll) as u16; let cursor_x = inner .x .saturating_add(prompt_width) .saturating_add(cursor_offset) .min(inner.x.saturating_add(inner.width.saturating_sub(1))); let cursor_y = inner.y; f.set_cursor_position((cursor_x, cursor_y)); } Dialog::CommandError { command, output } => { let dialog_text = format!("Command: {command}\n\n{output}"); let lines = dialog_text .lines() .count() .saturating_add(2) .min(u16::MAX as usize) as u16; let dialog = Paragraph::new(dialog_text) .style(Style::default().fg(Color::White)) .wrap(Wrap { trim: false }) .block( Block::default() .title("─Command Error") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().fg(Color::Red)), ); let area = centered_dialog_area(DIALOG_WIDTH, lines, f.area()); f.render_widget(Clear, area); f.render_widget(dialog, area); } } } } } fn chunked_string(s: &str, first_chunk_size: usize, chunk_size: usize) -> Vec<&str> { let stepped_indices = s .char_indices() .map(|(i, _)| i) .enumerate() .filter(|&(i, _)| { if i > (first_chunk_size) { chunk_size > 0 && (i - first_chunk_size).is_multiple_of(chunk_size) } else { i == 0 || i == first_chunk_size } }) .map(|(_, e)| e) .collect::>(); let windows = stepped_indices.windows(2).collect::>(); let iter = windows.iter().map(|w| &s[w[0]..w[1]]); let last_index = *stepped_indices.last().unwrap_or(&0); iter.chain(once(&s[last_index..])).collect() } fn fit_text( s: &'_ str, lines: usize, cols: usize, anchor: ScrollAnchor, offset: usize, wrap: bool, ) -> Text<'_> { let s = s.rsplit_once(['\r', '\n']).map_or(s, |(p, _)| p); // skip everything after last line delimiter let l = s.lines().flat_map(|l| l.split('\r')); // bandaid for term escape codes let iter = match anchor { ScrollAnchor::Top => Either::Left(l), ScrollAnchor::Bottom => Either::Right(l.rev()), }; let iter = iter .skip(offset) .flat_map(|l| { let iter = if wrap { Either::Left( chunked_string(l, cols, cols.saturating_sub(2)) .into_iter() .enumerate() .map(|(i, l)| { if i == 0 { Line::raw(l.chars().take(cols).collect::()) } else { Line::default().spans(vec![ Span::styled( "↪ ", Style::default().add_modifier(Modifier::DIM), ), Span::raw( l.chars().take(cols.saturating_sub(2)).collect::(), ), ]) } }), ) } else { match l.chars().nth(cols) { Some(_) => { // has more chars than cols Either::Right(once(Line::default().spans(vec![ Span::raw(l.chars().take(cols.saturating_sub(1)).collect::()), Span::styled("…", Style::default().add_modifier(Modifier::DIM)), ]))) } None => { Either::Right(once(Line::raw(l.chars().take(cols).collect::()))) } } }; match anchor { ScrollAnchor::Top => Either::Left(iter), ScrollAnchor::Bottom => Either::Right(iter.rev()), } }) .take(lines); match anchor { ScrollAnchor::Top => Text::from(iter.collect::>()), ScrollAnchor::Bottom => Text::from( iter.collect::>() .into_iter() .rev() .collect::>(), ), } } impl App { fn selected_job(&self) -> Option<&Job> { self.job_list_state .selected() .and_then(|i| self.jobs.get(i)) } fn selected_job_id(&self) -> Option { self.selected_job().map(Job::id) } fn focus_next_panel(&mut self) { match self.focus { Focus::Jobs => self.focus = Focus::Jobs, } } fn focus_previous_panel(&mut self) { match self.focus { Focus::Jobs => self.focus = Focus::Jobs, } } fn select_next_job(&mut self) { self.job_list_state.select_next(); } fn select_previous_job(&mut self) { self.job_list_state.select_previous(); } fn select_first_job(&mut self) { self.job_list_state.select_first(); } fn select_last_job(&mut self) { self.job_list_state.select_last(); } fn scroll_jobs_half_page_down(&mut self) { self.job_list_state.scroll_down_by(self.job_list_height / 2); } fn scroll_jobs_half_page_up(&mut self) { self.job_list_state.scroll_up_by(self.job_list_height / 2); } fn job_index_at(&self, column: u16, row: u16) -> Option { if self.jobs.is_empty() { return None; } let inner = Rect::new( self.job_list_area.x.saturating_add(1), self.job_list_area.y.saturating_add(1), self.job_list_area.width.saturating_sub(2), self.job_list_area.height.saturating_sub(2), ); if !rect_contains(inner, column, row) { return None; } let row_in_list = (row - inner.y) as usize; let index = self.job_list_state.offset().saturating_add(row_in_list); (index < self.jobs.len()).then_some(index) } fn scroll_job_output_down_by(&mut self, delta: u16) { match self.job_output_anchor { ScrollAnchor::Top => { self.job_output_offset = self.job_output_offset.saturating_add(delta) } ScrollAnchor::Bottom => { self.job_output_offset = self.job_output_offset.saturating_sub(delta) } } } fn scroll_job_output_up_by(&mut self, delta: u16) { match self.job_output_anchor { ScrollAnchor::Top => { self.job_output_offset = self.job_output_offset.saturating_sub(delta) } ScrollAnchor::Bottom => { self.job_output_offset = self.job_output_offset.saturating_add(delta) } } } } fn rect_contains(rect: Rect, column: u16, row: u16) -> bool { column >= rect.x && column < rect.x.saturating_add(rect.width) && row >= rect.y && row < rect.y.saturating_add(rect.height) } fn mouse_wheel_direction(kind: MouseEventKind) -> Option { match kind { MouseEventKind::ScrollUp => Some(MouseWheelDirection::Up), MouseEventKind::ScrollDown => Some(MouseWheelDirection::Down), _ => None, } } fn signal_index_for_digit(digit: char) -> Option { let value = digit.to_digit(10)? as usize; if value == 0 { None } else { Some(value - 1) } } fn validated_time_limit(input: &Input) -> Option { let time_limit = input.value().trim(); if time_limit.is_empty() { None } else { Some(time_limit.to_string()) } } fn execute_scancel(job_id: &str, signal: Option<&str>) -> Result<(), CommandFailure> { let mut command = Command::new("scancel"); let mut command_display = String::from("scancel"); if let Some(signal) = signal { command.arg("--signal").arg(signal); command_display.push_str(&format!(" --signal {signal}")); } command.arg(job_id); command_display.push_str(&format!(" {job_id}")); execute_command(command, command_display) } fn execute_scontrol_update_timelimit(job_id: &str, time_limit: &str) -> Result<(), CommandFailure> { let mut command = Command::new("scontrol"); command .arg("update") .arg(format!("JobId={job_id}")) .arg(format!("TimeLimit={time_limit}")); execute_command( command, format!("scontrol update JobId={job_id} TimeLimit={time_limit}"), ) } fn execute_command(mut command: Command, command_label: String) -> Result<(), CommandFailure> { let output = command.output().map_err(|error| CommandFailure { command: command_label.clone(), output: error.to_string(), })?; if output.status.success() { return Ok(()); } let mut details = vec![match output.status.code() { Some(code) => format!("Exit code: {code}"), None => "Exit code: N/A".to_string(), }]; let stdout = String::from_utf8_lossy(&output.stdout); let stdout = stdout.trim_end(); let stderr = String::from_utf8_lossy(&output.stderr); let stderr = stderr.trim_end(); let has_stdout = !stdout.is_empty(); let has_stderr = !stderr.is_empty(); match (has_stdout, has_stderr) { (true, true) => { details.push(format!("stdout:\n{stdout}")); details.push(format!("stderr:\n{stderr}")); } (true, false) => { details.push(stdout.to_string()); } (false, true) => { details.push(stderr.to_string()); } (false, false) => {} } if details.len() == 1 { details.push("No output.".to_string()); } Err(CommandFailure { command: command_label, output: details.join("\n\n"), }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_chunked_string() { // Divisible let input = "abcdefghij"; let expected = vec!["abcd", "ef", "gh", "ij"]; assert_eq!(chunked_string(input, 4, 2), expected); // Not divisible let input = "123456789"; let expected = vec!["1234", "56", "78", "9"]; assert_eq!(chunked_string(input, 4, 2), expected); // Smaller let input = "abc"; let expected = vec!["abc"]; assert_eq!(chunked_string(input, 4, 2), expected); // Smaller let input = "abcde"; let expected = vec!["abcd", "e"]; assert_eq!(chunked_string(input, 4, 2), expected); // Empty let input = ""; let expected: Vec<&str> = vec![""]; assert_eq!(chunked_string(input, 4, 2), expected); let input = "123456789"; let expected = vec!["1234", "56789"]; assert_eq!(chunked_string(input, 4, 0), expected); let input = "123456789"; let expected = vec!["12", "34", "56", "78", "9"]; assert_eq!(chunked_string(input, 0, 2), expected); let input = "123456789"; let expected = vec!["123456789"]; assert_eq!(chunked_string(input, 0, 0), expected); } #[test] fn test_validated_time_limit() { assert_eq!(validated_time_limit(&Input::new("".to_string())), None); assert_eq!(validated_time_limit(&Input::new(" ".to_string())), None); assert_eq!( validated_time_limit(&Input::new(" 01:00:00 ".to_string())), Some("01:00:00".to_string()) ); } } ================================================ FILE: src/file_watcher.rs ================================================ use std::{ fmt, fs::File, io::{self, Read, Seek}, path::{Path, PathBuf}, thread, time::Duration, }; use crossbeam::{ channel::{Receiver, RecvError, SendError, Sender, unbounded}, select, }; use notify::{RecursiveMode, Watcher, event::ModifyKind}; use crate::app::AppMessage; struct FileReader { content_sender: Sender>, receiver: Receiver<()>, file_path: PathBuf, interval: Duration, content: String, pos: u64, } struct FileWatcher { app: Sender, receiver: Receiver, file_path: Option, watching: bool, // Whether notify watch was successfully started for file_path interval: Duration, } pub enum FileWatcherMessage { FilePath(Option), } pub struct FileWatcherHandle { sender: Sender, file_path: Option, } pub enum FileWatcherError { File(io::Error), } impl fmt::Display for FileWatcherError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { FileWatcherError::File(e) => write!(f, "Read error: {}", e), } } } impl FileWatcher { fn new( app: Sender, receiver: Receiver, interval: Duration, ) -> Self { FileWatcher { app, receiver, file_path: None, watching: false, interval, } } fn run(&mut self) -> Result<(), RecvError> { let (watch_sender, watch_receiver) = unbounded::<()>(); let mut watcher = notify::recommended_watcher(move |res: notify::Result| { let event = res.unwrap(); if let notify::EventKind::Modify(ModifyKind::Data(_)) = event.kind { watch_sender.send(()).unwrap(); } }) .unwrap(); let (mut _content_sender, mut _content_receiver) = unbounded::>(); let (mut _watch_sender, mut _watch_receiver) = unbounded::<()>(); loop { select! { recv(self.receiver) -> msg => { match msg? { FileWatcherMessage::FilePath(file_path) => { (_content_sender, _content_receiver) = unbounded(); (_watch_sender, _watch_receiver) = unbounded::<()>(); if self.watching { let p = self.file_path.as_ref().expect("Inconsistent state"); watcher.unwatch(p).unwrap_or_else(|_| panic!("Failed to unwatch {:?}", p)); } self.file_path = None; self.watching = false; if let Some(p) = file_path { self.file_path = Some(p.clone()); let interval = self.interval; thread::spawn({ let p = p.clone(); move || FileReader::new(_content_sender, _watch_receiver, p, interval).run() }); self.watching = watcher.watch(Path::new(&p), RecursiveMode::NonRecursive).is_ok(); } else { _content_sender.send(Ok("".to_string())).unwrap(); } } } } recv(watch_receiver) -> _ => { _watch_sender.send(()).unwrap(); } recv(_content_receiver) -> msg => { let res = msg.unwrap(); // If we don't have a file watch yet but the file now reads OK, try enabling watch if !self.watching { if let (Ok(_), Some(p)) = (&res, &self.file_path) { self.watching = watcher.watch(Path::new(p), RecursiveMode::NonRecursive).is_ok(); } } self.app .send(AppMessage::JobOutput(res.map_err(FileWatcherError::File))) .unwrap(); } } } } } impl FileReader { fn new( content_sender: Sender>, receiver: Receiver<()>, file_path: PathBuf, interval: Duration, ) -> Self { FileReader { content_sender, receiver, file_path, interval, content: "".to_string(), pos: 0, } } fn run(&mut self) -> Result<(), ()> { loop { self.update().map_err(|_| ())?; select! { recv(self.receiver) -> msg => { msg.map_err(|_| ())?; } // in case the file watcher doesn't work (e.g. network mounted fs) default(self.interval) => {} } } } fn update(&mut self) -> Result<(), SendError>> { let s = File::open(&self.file_path).and_then(|mut f| { // avoid reading the whole file every time self.pos = f.seek(io::SeekFrom::Start(self.pos))?; self.pos += f.read_to_string(&mut self.content)? as u64; Ok(self.content.clone()) }); // let s = fs::read_to_string(&self.file_path); // alternative: always read the whole file self.content_sender.send(s) } } impl FileWatcherHandle { pub fn new(app: Sender, interval: Duration) -> Self { let (sender, receiver) = unbounded(); let mut actor = FileWatcher::new(app, receiver, interval); thread::spawn(move || actor.run()); Self { sender, file_path: None, } } pub fn set_file_path(&mut self, file_path: Option) { if self.file_path != file_path { self.file_path = file_path.clone(); self.sender .send(FileWatcherMessage::FilePath(file_path)) .unwrap(); } } } ================================================ FILE: src/job_watcher.rs ================================================ use std::path::PathBuf; use std::{io::BufRead, process::Command, thread, time::Duration}; use crossbeam::channel::Sender; use regex::Regex; use crate::app::AppMessage; use crate::app::Job; struct JobWatcher { app: Sender, interval: Duration, squeue_args: Vec, } pub struct JobWatcherHandle {} impl JobWatcher { fn new(app: Sender, interval: Duration, squeue_args: Vec) -> Self { Self { app, interval, squeue_args, } } fn run(&mut self) -> Self { let output_separator = "###turm###"; let fields = [ "jobid", "name", "state", "username", "timeused", "timelimit", "StartTime", "tres-alloc", "partition", "nodelist", "stdout", "stderr", "command", "statecompact", "reason", "ArrayJobID", // %A "ArrayTaskID", // %a "NodeList", // %N "WorkDir", // for fallback ]; let output_format = fields .map(|s| s.to_owned() + ":" + output_separator) .join(","); loop { let jobs: Vec = Command::new("squeue") .args(&self.squeue_args) .arg("--array") .arg("--noheader") .arg("--Format") .arg(&output_format) .output() .expect("failed to execute process") .stdout .lines() .map(|l| l.unwrap().trim().to_string()) .filter_map(|l| { let parts: Vec<_> = l.split(output_separator).collect(); if parts.len() != fields.len() + 1 { return None; } let id = parts[0]; let name = parts[1]; let state = parts[2]; let user = parts[3]; let time = parts[4]; let time_limit = parts[5]; let start_time = parts[6]; let tres = parts[7]; let partition = parts[8]; let nodelist = parts[9]; let stdout = parts[10]; let stderr = parts[11]; let command = parts[12]; let state_compact = parts[13]; let reason = parts[14]; let array_job_id = parts[15]; let array_task_id = parts[16]; let node_list = parts[17]; let working_dir = parts[18]; Some(Job { job_id: id.to_owned(), array_id: array_job_id.to_owned(), array_step: match array_task_id { "N/A" => None, _ => Some(array_task_id.to_owned()), }, name: name.to_owned(), state: state.to_owned(), state_compact: state_compact.to_owned(), reason: if reason == "None" { None } else { Some(reason.to_owned()) }, user: user.to_owned(), time: time.to_owned(), time_limit: time_limit.to_owned(), start_time: start_time.to_owned(), tres: tres.to_owned(), partition: partition.to_owned(), nodelist: nodelist.to_owned(), command: command.to_owned(), stdout: Self::resolve_path( stdout, array_job_id, array_task_id, id, node_list, user, name, working_dir, ), stderr: Self::resolve_path( stderr, array_job_id, array_task_id, id, node_list, user, name, working_dir, ), // TODO fill all fields }) }) .collect(); self.app.send(AppMessage::Jobs(jobs)).unwrap(); thread::sleep(self.interval); } } #[allow(clippy::too_many_arguments)] fn resolve_path( path: &str, array_master: &str, array_id: &str, id: &str, host: &str, user: &str, name: &str, working_dir: &str, ) -> Option { // see https://slurm.schedmd.com/sbatch.html#SECTION_%3CB%3Efilename-pattern%3C/B%3E lazy_static::lazy_static! { static ref RE: Regex = Regex::new(r"%(%|A|a|J|j|N|n|s|t|u|x)").unwrap(); } let mut path = path.to_owned(); let slurm_no_val = "4294967294"; let array_id = if array_id == "N/A" { slurm_no_val } else { array_id }; if path.is_empty() { // never happens right now, because `squeue -O stdout` seems to always return something path = if array_id == slurm_no_val { PathBuf::from(working_dir).join("slurm-%J.out") } else { PathBuf::from(working_dir).join("slurm-%A_%a.out") } .to_str() .unwrap() .to_owned(); }; for cap in RE .captures_iter(&path.clone()) .collect::>() // TODO: this is stupid, there has to be a better way to reverse the captures... .iter() .rev() { let m = cap.get(0).unwrap(); let replacement = match m.as_str() { "%%" => "%", "%A" => array_master, "%a" => array_id, "%J" => id, "%j" => id, "%N" => host.split(',').next().unwrap_or(host), "%n" => "0", "%s" => "batch", "%t" => "0", "%u" => user, "%x" => name, _ => unreachable!(), }; path.replace_range(m.range(), replacement); } Some(PathBuf::from(working_dir).join(path)) // works even if `path` is absolute } } impl JobWatcherHandle { pub fn new(app: Sender, interval: Duration, squeue_args: Vec) -> Self { let mut actor = JobWatcher::new(app, interval, squeue_args); thread::spawn(move || actor.run()); Self {} } } ================================================ FILE: src/main.rs ================================================ mod app; mod file_watcher; mod job_watcher; mod squeue_args; use app::App; use clap::CommandFactory; use clap::Parser; use clap::Subcommand; use clap_complete::{Shell, generate}; use crossbeam::channel::{Sender, unbounded}; use crossterm::{ cursor::Show, event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{ Terminal, backend::{Backend, CrosstermBackend}, }; use squeue_args::SqueueArgs; use std::io::Write; use std::{io, panic, thread}; #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { /// Refresh rate for the job watcher. #[arg(long, value_name = "SECONDS", default_value_t = 2)] slurm_refresh: u64, /// Refresh rate for the file watcher. #[arg(long, value_name = "SECONDS", default_value_t = 2)] file_refresh: u64, /// squeue arguments #[command(flatten)] squeue_args: SqueueArgs, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum CliCommand { /// Print shell completion script to stdout. Completion { /// The shell to generate completion for. shell: Shell, }, } fn main() -> io::Result<()> { let args = Cli::parse(); match args.command { Some(CliCommand::Completion { shell }) => { let cmd = &mut Cli::command(); generate(shell, cmd, cmd.get_name().to_string(), &mut io::stdout()); return Ok(()); } None => {} } install_panic_hook(); let mut terminal_guard = TerminalGuard::new(io::stdout())?; run_app(terminal_guard.terminal_mut(), args) } fn install_panic_hook() { let default_hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { let _ = disable_raw_mode(); let _ = execute!( io::stdout(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture, Show ); default_hook(panic_info); })); } struct TerminalGuard { terminal: Terminal>, } impl TerminalGuard { fn new(mut writer: W) -> io::Result { enable_raw_mode()?; execute!( writer, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture )?; let backend = CrosstermBackend::new(writer); let terminal = Terminal::new(backend)?; Ok(Self { terminal }) } fn terminal_mut(&mut self) -> &mut Terminal> { &mut self.terminal } } impl Drop for TerminalGuard { fn drop(&mut self) { let _ = disable_raw_mode(); let _ = execute!( self.terminal.backend_mut(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture ); let _ = self.terminal.show_cursor(); } } fn input_loop(tx: Sender>) { while tx.send(event::read()).is_ok() {} } fn run_app>(terminal: &mut Terminal, args: Cli) -> io::Result<()> { let (input_tx, input_rx) = unbounded(); let mut app = App::new( input_rx, args.slurm_refresh, args.file_refresh, args.squeue_args.to_vec(), ); thread::spawn(move || input_loop(input_tx)); app.run(terminal) } ================================================ FILE: src/squeue_args.rs ================================================ use clap::Args; /// Doc comment #[derive(Args, Debug)] pub struct SqueueArgs { /// |squeue arg| Comma separated list of accounts to view, default is all accounts. #[arg(short = 'A', long)] account: Option, /// |squeue arg| Display jobs in hidden partitions. #[arg(short, long)] all: bool, /// |squeue arg| Report federated information if a member of one. #[arg(long)] federation: bool, /// |squeue arg| Do not display jobs in hidden partitions. #[arg(long)] hide: bool, /// |squeue arg| Comma separated list of jobs IDs to view, default is all. #[arg(short, long, value_name = "JOBID")] job: Option, /// |squeue arg| Report information only about jobs on the local cluster. Overrides `--federation`. #[arg(long)] local: bool, /// |squeue arg| Comma separated list of license names to view. #[arg(short = 'L', long)] licenses: Option, /// |squeue arg| Cluster to issue commands to. Default is current cluster. Cluster with no name will reset to default. Implies `--local`. #[arg(short = 'M', long)] clusters: Option, /// |squeue arg| Equivalent to `--user=`. #[arg(long)] me: bool, /// |squeue arg| Comma separated list of job names to view. #[arg(short = 'n', long)] name: Option, /// |squeue arg| Don't convert units from their original type (e.g. 2048M won't be converted to 2G). #[arg(long)] noconvert: bool, /// |squeue arg| Comma separated list of partitions to view, default is all partitions. #[arg(short, long)] partition: Option, /// |squeue arg| Comma separated list of qos's to view, default is all qos's. #[arg(short, long)] qos: Option, /// |squeue arg| Reservation to view, default is all. #[arg(short = 'R', long)] reservation: Option, /// |squeue arg| Report information about all sibling jobs on a federated cluster. Implies --federation. #[arg(long)] sibling: bool, /// |squeue arg| Comma separated list of job steps to view, default is all. #[arg(short, long)] step: Option, /// |squeue arg| Comma separated list of fields to sort on. #[arg(short = 'S', long, value_name = "FIELDS")] sort: Option, /// |squeue arg| Comma separated list of states to view, default is pending and running, `--states=all` reports all states. #[arg(short = 't', long)] states: Option, /// |squeue arg| Comma separated list of users to view. #[arg(short = 'u', long)] user: Option, /// |squeue arg| List of nodes to view, default is all nodes. #[arg(short = 'w', long, value_name = "NODES")] nodelist: Option, } impl SqueueArgs { pub fn to_vec(&self) -> Vec { let mut args = Vec::new(); if let Some(account) = &self.account { args.push(format!("--account={}", account)); } if self.all { args.push("--all".to_string()); } if self.federation { args.push("--federation".to_string()); } if self.hide { args.push("--hide".to_string()); } if let Some(job) = &self.job { args.push(format!("--job={}", job)); } if self.local { args.push("--local".to_string()); } if let Some(licenses) = &self.licenses { args.push(format!("--licenses={}", licenses)); } if let Some(clusters) = &self.clusters { args.push(format!("--clusters={}", clusters)); } if self.me { args.push("--me".to_string()); } if let Some(name) = &self.name { args.push(format!("--name={}", name)); } if self.noconvert { args.push("--noconvert".to_string()); } if let Some(partition) = &self.partition { args.push(format!("--partition={}", partition)); } if let Some(qos) = &self.qos { args.push(format!("--qos={}", qos)); } if let Some(reservation) = &self.reservation { args.push(format!("--reservation={}", reservation)); } if self.sibling { args.push("--sibling".to_string()); } if let Some(step) = &self.step { args.push(format!("--step={}", step)); } if let Some(sort) = &self.sort { args.push(format!("--sort={}", sort)); } if let Some(states) = &self.states { args.push(format!("--states={}", states)); } if let Some(user) = &self.user { args.push(format!("--user={}", user)); } if let Some(nodelist) = &self.nodelist { args.push(format!("--nodelist={}", nodelist)); } args } }