Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c2b8ac0
[Fixed] Restore the terminal on panic
fedonman Aug 3, 2026
1c57631
[Fixed] Panic-proof the live file watcher
fedonman Aug 3, 2026
7102d67
[Fixed] Report background worker failures instead of freezing
fedonman Aug 3, 2026
6d7ffc6
[Fixed] Surface squeue failures instead of an empty table
fedonman Aug 3, 2026
4cd99d8
[Added] Follow mode for log and custom widgets
fedonman Aug 3, 2026
c428a81
[Fixed] Decode the %R reason column
fedonman Aug 3, 2026
574a280
[Changed] Use a control character as the squeue field separator
fedonman Aug 3, 2026
ea26c0a
[Fixed] Drop the hardcoded QoS fallback list
fedonman Aug 3, 2026
6a9c5a4
[Changed] Consolidate the regex filter pipeline and focus cycling
fedonman Aug 3, 2026
4e5bca6
[Fixed] Honest clipboard feedback, sidebar underflow, and LRU detail …
fedonman Aug 3, 2026
0a01a5d
[Added] Configurable auto-refresh interval
fedonman Aug 3, 2026
2a9f4cc
[Added] In-app help overlay
fedonman Aug 3, 2026
f9daf7b
[Changed] Add a library target so the crate can be tested
fedonman Aug 3, 2026
1132a66
[Added] Unit tests for query, state, and filter logic
fedonman Aug 3, 2026
a361477
[Added] Fixture-based parsing tests with per-field round-trip guard
fedonman Aug 3, 2026
73a9c85
[Changed] Harden the CI and release pipeline and fix the MSRV changelog
fedonman Aug 3, 2026
92c5c26
[Changed] Cut the 0.2.0 release
fedonman Aug 28, 2026
7ca8c62
[Changed] Upgrade dependencies to latest versions
fedonman Aug 28, 2026
024090f
[Fixed] Bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204
fedonman Aug 28, 2026
3be57ae
[Fixed] Let the changelog CI gate pass on release PRs
fedonman Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
Expand Down Expand Up @@ -48,7 +48,7 @@ jobs:
with:
toolchain: "1.90.0"
- uses: Swatinem/rust-cache@v2
- run: cargo check
- run: cargo check --all-targets

deny:
name: Cargo Deny
Expand All @@ -65,13 +65,19 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check CHANGELOG.md was updated
- name: Check CHANGELOG documents this PR's changes
run: |
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
if git diff --name-only "$BASE_SHA"..."$HEAD_SHA" | grep -q '^CHANGELOG.md$'; then
echo "CHANGELOG.md was updated."
else
echo "::error::CHANGELOG.md was not updated. Every PR must include a changelog entry."
exit 1
section=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
if echo "$section" | grep -q '^- '; then
echo "Found an Unreleased changelog entry."
exit 0
fi
# A release PR promotes [Unreleased] into a dated version section, which
# legitimately leaves [Unreleased] empty. Accept it if this PR adds one.
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
if git diff FETCH_HEAD -- CHANGELOG.md | grep -qE '^\+## \[[0-9]'; then
echo "Release PR: a new version section was added; empty [Unreleased] is expected."
exit 0
fi
echo "::error::Add a bullet under '## [Unreleased]' in CHANGELOG.md."
exit 1
14 changes: 12 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2

- name: Ensure release runs from main
run: |
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
echo "::error::Releases must be dispatched from the main branch."
exit 1
fi

- name: Validate version format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
Expand All @@ -50,10 +57,13 @@ jobs:
fi

- name: Run tests
run: cargo test
run: cargo test --all-targets --locked

- name: Package dry run
run: cargo publish --locked --dry-run

- name: Publish to crates.io
run: cargo publish
run: cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-08-28

### Added

- Follow/tail mode for the stdout, stderr, and custom file widgets: the view snaps to the bottom as new content arrives, with `f`, `End`, and `Home` to control following and a `[follow]` indicator in the panel title.
- Configurable auto-refresh interval, adjustable at runtime with `+`/`-` (1–60s, default 3s) and persisted to `settings.json`.
- In-app help overlay listing all global and per-focus keybindings, opened with `?`.
- A library target and an initial test suite covering `squeue` argument building, job-state parsing, regex filtering, and fixture-based output decoding, including a round-trip guard that every displayable column is actually decoded.

### Changed

- The `squeue --format` field separator changed from `|` to an ASCII control character so job names containing `|` no longer corrupt column parsing.
- The regex filter pipeline and focus cycling were consolidated, saved filter patterns are validated once on load, and `Shift+Tab` is now accepted regardless of the reported modifier.
- Dependencies were refreshed to their latest compatible versions (including `ratatui` 0.30.2, `tokio` 1.53, and `regex` 1.13), and `base64` was upgraded to 0.23.

### Fixed

- The terminal is restored on panic via an RAII guard and a chained panic hook, so a crash no longer leaves the shell stuck in raw mode on the alternate screen.
- The live file watcher no longer panics on inotify limits or channel errors; failures are reported through the widget instead of taking down the app.
- Background worker failures (the input thread and the job fetcher) are surfaced instead of silently freezing the UI.
- `squeue` failures — a non-zero exit, an unreachable controller, or a bad sort key — are shown in the flash bar instead of being rendered as a normal empty table.
- The `Reason` (`%R`) column is now decoded and populated.
- The hardcoded `normal`/`huge` QoS fallback was removed so the QoS filter reflects the actual cluster.
- Clipboard copies report real success or failure, a debug-build width underflow in the filter sidebar was fixed, and the job-detail cache now evicts least-recently-used entries instead of clearing wholesale.

## [0.1.1] - 2026-03-26

### Added

- Three operations that previously blocked the main thread and froze the UI — `scontrol show job` lookups, periodic `squeue` refreshes, and script file loading with optional `bat` highlighting — were moved into dedicated background threads. A new `JobDetailResolver` runs a single `scontrol` call per job and caches up to 64 results, replacing the duplicate per-widget calls that each blocked for 100–500 ms; widgets now show a "Loading…" placeholder until the detail arrives, and the resolver deduplicates rapid requests by draining the channel and keeping only the latest job ID. A new `JobFetcher` runs `squeue` in its own lightweight tokio runtime so the 1-second auto-refresh and filter-apply no longer stall rendering; the old synchronous `reload_jobs` was split into `reload_jobs_sync` (used once at startup) and a non-blocking `submit_reload` path whose results are picked up on the next timer tick. The script widget's `load_content` was similarly offloaded to a background thread so that file reads and `bat` invocations never touch the render path, with a new `poll_updates` method that mirrors the pattern already used by the output and custom widgets. The input processing loop now drains all pending signals on each iteration and collapses consecutive `Timer` events into a single tick, which eliminates the multi-second freeze that occurred when switching back to the terminal after the window had been unfocused and hundreds of stale timers had piled up in the channel. `Ctrl+C` while any content widget (script, stdout, stderr, or custom) is focused now copies the widget's content to the system clipboard via the OSC 52 escape sequence and flashes a confirmation in the titlebar; the binding works over SSH and inside tmux without requiring X11 or Wayland, and `Esc` remains the key for returning focus to the table. The script widget gained `PageUp`/`PageDown` and `Ctrl+U`/`Ctrl+D` scrolling to match the other widgets, and all four content widget types now show `PgUp/Dn Scroll` and `Ctrl+C Copy` hints in the statusbar. [PR #5](https://github.com/fedonman/sqwatch/pull/5)

- Added CI/CD infrastructure so that every pull request and push to main is automatically checked for formatting, linting, test correctness, minimum supported Rust version compatibility (1.85.0), and dependency license and vulnerability audits via `cargo-deny`. Pull requests now require a changelog entry before merging. A separate manually-triggered release workflow handles version validation, publishing to crates.io, and creating GitHub Releases with the relevant changelog section as release notes. [PR #1](https://github.com/fedonman/sqwatch/pull/1)
- Added CI/CD infrastructure so that every pull request and push to main is automatically checked for formatting, linting, test correctness, minimum supported Rust version compatibility (1.90), and dependency license and vulnerability audits via `cargo-deny`. Pull requests now require a changelog entry before merging. A separate manually-triggered release workflow handles version validation, publishing to crates.io, and creating GitHub Releases with the relevant changelog section as release notes. [PR #1](https://github.com/fedonman/sqwatch/pull/1)

### Changed

Expand Down
16 changes: 11 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sqwatch"
version = "0.1.1"
version = "0.2.0"
edition = "2024"
description = "A terminal UI for watching and managing SLURM job queues."
authors = ["Vyron Vasileiadis <hi@fedonman.com>"]
Expand All @@ -26,4 +26,4 @@ regex = "1.12.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_norway = "0.9"
base64 = "0.22"
base64 = "0.23"
20 changes: 6 additions & 14 deletions src/backend/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ pub async fn run_cmd(program: &str, args: Vec<String>) -> Result<Output> {

/// Parsed result of `scontrol show job <id> -o`.
#[derive(Clone)]
#[expect(
dead_code,
reason = "work_dir is parsed for completeness and cached by JobDetailResolver"
)]
pub struct JobDetail {
pub stdout_file: Option<String>,
pub stderr_file: Option<String>,
Expand Down Expand Up @@ -140,19 +136,15 @@ pub async fn list_qos() -> Vec<String> {
)
.await
{
Ok(o) => o,
Err(_) => return vec!["normal".into(), "huge".into()],
Ok(o) if o.status.success() => o,
// No accounting DB / QoS on this cluster: show an empty list rather
// than inventing site-specific names that don't exist here.
_ => return Vec::new(),
};

let items: Vec<String> = String::from_utf8_lossy(&out.stdout)
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();

if items.is_empty() {
vec!["normal".into(), "huge".into()]
} else {
items
}
.collect()
}
32 changes: 31 additions & 1 deletion src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ impl FromStr for JobState {
}
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub job_id: String,
pub name: String,
Expand Down Expand Up @@ -125,3 +125,33 @@ impl Default for Job {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_long_and_short_state_codes() {
assert_eq!(JobState::from_str("RUNNING").unwrap(), JobState::Running);
assert_eq!(JobState::from_str("R").unwrap(), JobState::Running);
assert_eq!(JobState::from_str("pd").unwrap(), JobState::Pending);
assert_eq!(
JobState::from_str("OUT_OF_MEMORY").unwrap(),
JobState::OutOfMemory
);
assert_eq!(JobState::from_str("OOM").unwrap(), JobState::OutOfMemory);
}

#[test]
fn unknown_state_falls_back_to_unknown() {
assert_eq!(JobState::from_str("NONSENSE").unwrap(), JobState::Unknown);
}

#[test]
fn display_round_trips_through_from_str() {
for st in JobState::all_known() {
let shown = st.to_string();
assert_eq!(JobState::from_str(&shown).unwrap(), st, "state {:?}", st);
}
}
}
Loading