├── .cargo └── audit.toml ├── .editorconfig ├── .githooks ├── README.md └── pre-commit ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .mise.toml ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── ROADMAP.md ├── deny.toml ├── examples └── config.toml ├── packaging ├── .gitignore ├── README.md ├── generate_packages.py ├── homebrew │ └── lazycelery.rb └── scoop │ └── lazycelery.json ├── rustfmt.toml ├── screenshots ├── help-screen.png ├── queues-view.png ├── search-mode.png ├── tasks-view.png └── workers-view.png ├── scripts └── validate-versions.py ├── src ├── app │ ├── actions.rs │ ├── mod.rs │ └── state.rs ├── broker │ ├── mod.rs │ └── redis │ │ ├── facade.rs │ │ ├── mod.rs │ │ ├── operations.rs │ │ ├── pool.rs │ │ └── protocol │ │ ├── mod.rs │ │ ├── queue_parser.rs │ │ ├── task_parser.rs │ │ └── worker_parser.rs ├── config.rs ├── error.rs ├── lib.rs ├── main.rs ├── models │ ├── mod.rs │ ├── queue.rs │ ├── task.rs │ └── worker.rs ├── ui │ ├── events.rs │ ├── layout.rs │ ├── mod.rs │ ├── modals.rs │ └── widgets │ │ ├── base.rs │ │ ├── mod.rs │ │ ├── queues.rs │ │ ├── tasks.rs │ │ └── workers.rs └── utils │ ├── formatting.rs │ └── mod.rs └── tests ├── integration_test.rs ├── redis_test_utils.rs ├── test_app.rs ├── test_app_actions.rs ├── test_broker_utils.rs ├── test_complete_integration.rs ├── test_config.rs ├── test_error.rs ├── test_event_handling.rs ├── test_models.rs ├── test_redis_broker_basic.rs ├── test_redis_broker_integration.rs ├── test_ui_base_widgets.rs ├── test_ui_layout.rs ├── test_ui_modals.rs ├── test_ui_widgets.rs ├── test_utils.rs └── test_utils_formatting.rs /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | # Audit configuration for cargo-audit 2 | [advisories] 3 | # Ignore the paste crate unmaintained warning as it's a transitive dependency 4 | # from ratatui and not a security vulnerability 5 | ignore = [ 6 | "RUSTSEC-2024-0436" # paste - no longer maintained 7 | ] -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | 13 | # Rust files 14 | [*.rs] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | # TOML files 19 | [*.toml] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | # YAML files 24 | [*.{yml,yaml}] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | # Markdown files 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | 32 | # Makefile 33 | [Makefile] 34 | indent_style = tab -------------------------------------------------------------------------------- /.githooks/README.md: -------------------------------------------------------------------------------- 1 | # Git Hooks 2 | 3 | This directory contains custom git hooks for the LazyCelery project. 4 | 5 | ## Setup 6 | 7 | To enable the pre-commit hook, run: 8 | 9 | ```bash 10 | git config core.hooksPath .githooks 11 | ``` 12 | 13 | This will configure git to use the hooks in this directory instead of `.git/hooks/`. 14 | 15 | ## Hooks 16 | 17 | ### pre-commit 18 | 19 | Runs before each commit and performs the following checks: 20 | 21 | 1. **Code formatting** - Ensures code is properly formatted with `rustfmt` 22 | 2. **Linting** - Runs `clippy` to catch common issues and style violations 23 | 3. **Tests** - Runs the full test suite to ensure nothing is broken 24 | 4. **Security audit** - Checks for known security vulnerabilities in dependencies 25 | 26 | If any check fails, the commit is aborted and you'll need to fix the issues before committing. 27 | 28 | ## Bypassing Hooks 29 | 30 | In emergency situations, you can bypass the pre-commit hook with: 31 | 32 | ```bash 33 | git commit --no-verify 34 | ``` 35 | 36 | However, this should be used sparingly as it defeats the purpose of having quality checks. 37 | 38 | ## Requirements 39 | 40 | - `mise` must be installed and configured 41 | - All mise tasks must be properly set up (`fmt`, `lint`, `test`, `audit`) -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Pre-commit hook for LazyCelery 4 | # Runs formatting, linting, and tests before allowing commit 5 | # 6 | 7 | set -e 8 | 9 | echo "🔍 Running pre-commit checks..." 10 | 11 | # Check if mise is available 12 | if ! command -v mise &> /dev/null; then 13 | echo "❌ mise is required but not installed. Please install mise first." 14 | exit 1 15 | fi 16 | 17 | # 1. Check formatting 18 | echo "📝 Checking code formatting..." 19 | if ! mise run fmt; then 20 | echo "❌ Code formatting check failed!" 21 | echo "💡 Run 'mise run fmt' to fix formatting issues" 22 | exit 1 23 | fi 24 | echo "✅ Code formatting is correct" 25 | 26 | # 2. Run linting 27 | echo "🔍 Running clippy lints..." 28 | if ! mise run lint; then 29 | echo "❌ Linting failed!" 30 | echo "💡 Fix the clippy warnings above" 31 | exit 1 32 | fi 33 | echo "✅ All linting checks passed" 34 | 35 | # 3. Run tests 36 | echo "🧪 Running tests..." 37 | if ! mise run test; then 38 | echo "❌ Tests failed!" 39 | echo "💡 Fix the failing tests above" 40 | exit 1 41 | fi 42 | echo "✅ Tests passed" 43 | 44 | # 4. Run security audit 45 | echo "🔒 Running security audit..." 46 | if ! mise run audit; then 47 | echo "❌ Security audit failed!" 48 | echo "💡 Fix the security issues above" 49 | exit 1 50 | fi 51 | echo "✅ Security audit passed" 52 | 53 | echo "🎉 All pre-commit checks passed! Proceeding with commit..." -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for Cargo 4 | - package-ecosystem: "cargo" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | 10 | # Enable version updates for GitHub Actions 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | open-pull-requests-limit: 5 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUST_BACKTRACE: 1 12 | # Aggressive cargo optimizations to avoid re-downloading crates 13 | CARGO_NET_RETRY: 10 14 | # Disable incremental compilation for CI builds (more deterministic) 15 | CARGO_INCREMENTAL: 0 16 | CARGO_NET_GIT_FETCH_WITH_CLI: true 17 | # Use sparse registry for faster index updates 18 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse 19 | # Additional optimizations 20 | CARGO_HTTP_MULTIPLEXING: false 21 | CARGO_NET_OFFLINE: false 22 | # Reduce memory usage in parallel builds 23 | CARGO_BUILD_JOBS: 4 24 | 25 | jobs: 26 | # Detect what types of changes were made 27 | detect-changes: 28 | name: Detect Changes 29 | runs-on: ubuntu-latest 30 | outputs: 31 | code: ${{ steps.filter.outputs.code }} 32 | docs: ${{ steps.filter.outputs.docs }} 33 | steps: 34 | - uses: actions/checkout@v5 35 | 36 | - name: Detect file changes 37 | uses: dorny/paths-filter@v3 38 | id: filter 39 | with: 40 | filters: | 41 | code: 42 | - 'src/**' 43 | - 'Cargo.*' 44 | - '.github/workflows/**' 45 | - 'tests/**' 46 | - '.cargo/**' 47 | docs: 48 | - '*.md' 49 | - 'docs/**' 50 | - 'screenshots/**' 51 | 52 | # Combine quick checks into one job for faster feedback 53 | quality-checks: 54 | name: Code Quality (Format, Lint, Check) 55 | runs-on: ubuntu-latest 56 | needs: detect-changes 57 | if: needs.detect-changes.outputs.code == 'true' 58 | steps: 59 | - uses: actions/checkout@v5 60 | 61 | - name: Setup optimized Rust cache 62 | uses: Swatinem/rust-cache@v2 63 | with: 64 | # Shared key for quality checks 65 | shared-key: "quality-checks" 66 | # Let rust-cache handle standard directories automatically 67 | cache-on-failure: true 68 | save-if: ${{ github.ref == 'refs/heads/main' }} 69 | # Include Cargo.lock in key for more precise caching 70 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 71 | # Cache workspaces for better incremental builds 72 | workspaces: "." 73 | 74 | - name: Install mise (cached) 75 | uses: jdx/mise-action@v3 76 | with: 77 | install: true 78 | cache: true 79 | github_token: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | - name: Cache Rustup toolchain 82 | uses: actions/cache@v4 83 | with: 84 | path: | 85 | ~/.rustup/settings.toml 86 | ~/.rustup/toolchains 87 | ~/.rustup/update-hashes 88 | key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml', 'rust-toolchain') }} 89 | restore-keys: | 90 | ${{ runner.os }}-rustup- 91 | 92 | - name: Install Rust components (cached) 93 | run: | 94 | rustup component add clippy rustfmt 95 | 96 | - name: Cache cargo check 97 | run: | 98 | # Use cargo check with cache optimization 99 | if ! cargo check --locked; then 100 | echo "❌ Cargo check failed" 101 | exit 1 102 | fi 103 | echo "✅ Cargo check passed" 104 | 105 | - name: Check formatting (fast) 106 | run: | 107 | if ! cargo fmt --all --check; then 108 | echo "❌ Code is not formatted correctly" 109 | echo "Run 'cargo fmt' to fix formatting" 110 | exit 1 111 | fi 112 | echo "✅ Code formatting is correct" 113 | 114 | - name: Run clippy (cached) 115 | run: | 116 | if ! cargo clippy --locked --all-targets --all-features -- -D warnings; then 117 | echo "❌ Linting failed" 118 | exit 1 119 | fi 120 | echo "✅ All linting checks passed" 121 | 122 | test: 123 | name: Test 124 | runs-on: ubuntu-latest 125 | needs: detect-changes 126 | if: needs.detect-changes.outputs.code == 'true' 127 | services: 128 | redis: 129 | image: redis:alpine 130 | ports: 131 | - 6379:6379 132 | options: >- 133 | --health-cmd "redis-cli ping" 134 | --health-interval 3s 135 | --health-timeout 2s 136 | --health-retries 2 137 | 138 | steps: 139 | - uses: actions/checkout@v5 140 | 141 | - name: Setup optimized Rust cache for tests 142 | uses: Swatinem/rust-cache@v2 143 | with: 144 | # Maximum caching for tests 145 | shared-key: "test-deps" 146 | cache-on-failure: true 147 | save-if: ${{ github.ref == 'refs/heads/main' }} 148 | # Cache all targets for comprehensive testing 149 | cache-targets: "true" 150 | cache-all-crates: "true" 151 | # Include Cargo.lock in key for more precise caching 152 | key: ${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} 153 | workspaces: "." 154 | 155 | - name: Install mise (cached) 156 | uses: jdx/mise-action@v3 157 | with: 158 | install: true 159 | cache: true 160 | github_token: ${{ secrets.GITHUB_TOKEN }} 161 | 162 | - name: Cache APT packages 163 | uses: awalsh128/cache-apt-pkgs-action@v1 164 | with: 165 | packages: redis-tools 166 | version: 1.0 167 | 168 | - name: Run tests with maximum optimization 169 | run: | 170 | echo "Running tests with cache optimization..." 171 | # Use locked deps and optimized flags for faster test execution 172 | if ! cargo test --locked --release --jobs $(nproc) -- --test-threads $(nproc); then 173 | echo "❌ Tests failed" 174 | exit 1 175 | fi 176 | echo "✅ All tests passed" 177 | 178 | security: 179 | name: Security Audit 180 | runs-on: ubuntu-latest 181 | needs: detect-changes 182 | if: needs.detect-changes.outputs.code == 'true' 183 | steps: 184 | - uses: actions/checkout@v5 185 | 186 | - name: Setup optimized Rust cache for security 187 | uses: Swatinem/rust-cache@v2 188 | with: 189 | shared-key: "security-audit" 190 | cache-on-failure: true 191 | save-if: ${{ github.ref == 'refs/heads/main' }} 192 | 193 | - name: Install mise (cached) 194 | uses: jdx/mise-action@v3 195 | with: 196 | install: true 197 | cache: true 198 | github_token: ${{ secrets.GITHUB_TOKEN }} 199 | 200 | - name: Cache cargo-audit binary 201 | uses: actions/cache@v4 202 | with: 203 | path: ~/.cargo/bin/cargo-audit 204 | key: cargo-audit-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} 205 | restore-keys: | 206 | cargo-audit-${{ runner.os }}- 207 | 208 | - name: Install cargo-audit if not cached 209 | run: | 210 | if ! command -v cargo-audit &> /dev/null; then 211 | echo "Installing cargo-audit..." 212 | cargo install cargo-audit --locked 213 | else 214 | echo "✅ cargo-audit already cached" 215 | fi 216 | 217 | - name: Run security audit 218 | run: | 219 | # Run security audit (cargo audit doesn't support --locked flag) 220 | if ! cargo audit; then 221 | echo "❌ Security audit failed" 222 | exit 1 223 | fi 224 | echo "✅ Security audit passed" 225 | 226 | build: 227 | name: Build (${{ matrix.os }}) 228 | runs-on: ${{ matrix.os }} 229 | needs: detect-changes 230 | if: needs.detect-changes.outputs.code == 'true' 231 | strategy: 232 | fail-fast: false 233 | matrix: 234 | os: [ubuntu-latest, windows-latest, macos-latest] 235 | 236 | steps: 237 | - uses: actions/checkout@v5 238 | 239 | - name: Setup optimized build cache 240 | uses: Swatinem/rust-cache@v2 241 | with: 242 | shared-key: "build-${{ matrix.os }}" 243 | cache-on-failure: true 244 | save-if: ${{ github.ref == 'refs/heads/main' }} 245 | cache-targets: "true" 246 | cache-all-crates: "true" 247 | 248 | - name: Install mise (cached) 249 | uses: jdx/mise-action@v3 250 | with: 251 | install: true 252 | cache: true 253 | github_token: ${{ secrets.GITHUB_TOKEN }} 254 | 255 | - name: Build release with maximum cache optimization 256 | run: | 257 | echo "Building on ${{ matrix.os }}" 258 | # Use locked deps and parallel compilation 259 | cargo build --locked --release --jobs $(nproc || echo 4) 260 | echo "✅ Build successful on ${{ matrix.os }}" 261 | 262 | - name: Test binary (Unix) 263 | if: matrix.os != 'windows-latest' 264 | run: | 265 | ./target/release/lazycelery --help 266 | echo "✅ Binary works correctly" 267 | 268 | - name: Test binary (Windows) 269 | if: matrix.os == 'windows-latest' 270 | run: | 271 | .\target\release\lazycelery.exe --help 272 | echo "✅ Binary works correctly" 273 | 274 | # Lightweight docs check for documentation-only changes 275 | docs-check: 276 | name: Documentation Check 277 | runs-on: ubuntu-latest 278 | needs: detect-changes 279 | if: needs.detect-changes.outputs.docs == 'true' && needs.detect-changes.outputs.code == 'false' 280 | steps: 281 | - uses: actions/checkout@v5 282 | 283 | - name: Check markdown files 284 | run: | 285 | echo "📝 Checking documentation files..." 286 | # Simple check for broken markdown links (basic validation) 287 | find . -name "*.md" -exec echo "Checking {}" \; 288 | echo "✅ Documentation check completed" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target 3 | **/*.rs.bk 4 | 5 | # Editor directories and files 6 | .idea/ 7 | .vscode/ 8 | *.swp 9 | *.swo 10 | *~ 11 | 12 | # OS files 13 | .DS_Store 14 | Thumbs.db 15 | desktop.ini 16 | 17 | # Environment 18 | .env 19 | .env.* 20 | .mise.local.toml 21 | 22 | # Logs 23 | *.log 24 | 25 | # Temporary files 26 | *.tmp 27 | *.temp 28 | 29 | # Output files 30 | repomix-output.txt 31 | repomix-output.md 32 | 33 | # Coverage 34 | *.profraw 35 | cobertura.xml 36 | coverage/ 37 | tarpaulin-report.html 38 | 39 | # Benchmarks 40 | /target/criterion 41 | 42 | # Documentation 43 | /target/doc 44 | 45 | # Backup files 46 | *.bak 47 | *.orig 48 | 49 | # Development and testing files 50 | test_env/ 51 | __pycache__/ 52 | *.pyc 53 | *.pyo 54 | # test_*.py 55 | # test_*.rs 56 | *.pid 57 | MVP_DEVELOPMENT_PLAN.md 58 | celery_worker.log 59 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | # mise configuration for LazyCelery 2 | # https://mise.jdx.dev/ 3 | 4 | [env] 5 | RUST_LOG = "info" 6 | RUST_BACKTRACE = "1" 7 | 8 | [tools] 9 | rust = "1.88.0" 10 | git-cliff = "latest" 11 | 12 | [tasks.build] 13 | description = "Build the project in release mode" 14 | run = "cargo build --release" 15 | 16 | [tasks.dev] 17 | description = "Run in development mode with auto-reload" 18 | run = [ 19 | "cargo install cargo-watch", 20 | "cargo watch -x 'run -- --broker redis://localhost:6379/0'" 21 | ] 22 | 23 | [tasks.test] 24 | description = "Run all tests" 25 | run = "cargo test --all-features" 26 | 27 | [tasks.test-watch] 28 | description = "Run tests in watch mode" 29 | run = "cargo watch -x test" 30 | 31 | [tasks.lint] 32 | description = "Run clippy linter" 33 | run = "cargo clippy --all-targets --all-features -- -D warnings" 34 | 35 | [tasks.fmt] 36 | description = "Format code" 37 | run = "cargo fmt --all" 38 | 39 | [tasks.check] 40 | description = "Check formatting and linting" 41 | run = [ 42 | "cargo fmt --all -- --check", 43 | "cargo clippy --all-targets --all-features -- -D warnings" 44 | ] 45 | 46 | [tasks.clean] 47 | description = "Clean build artifacts" 48 | run = [ 49 | "cargo clean", 50 | "rm -rf target/" 51 | ] 52 | 53 | [tasks.audit] 54 | description = "Run security audit" 55 | run = [ 56 | "cargo install cargo-audit", 57 | "cargo audit" 58 | ] 59 | 60 | [tasks.coverage] 61 | description = "Generate test coverage report" 62 | run = [ 63 | "cargo install cargo-tarpaulin", 64 | "cargo tarpaulin --verbose --all-features --workspace --timeout 120 --out html" 65 | ] 66 | 67 | [tasks.docs] 68 | description = "Generate and open documentation" 69 | run = "cargo doc --no-deps --open" 70 | 71 | [tasks.install] 72 | description = "Install locally" 73 | run = "cargo install --path ." 74 | 75 | [tasks.run] 76 | description = "Run with Redis broker" 77 | run = "cargo run -- --broker redis://localhost:6379/0" 78 | depends = ["redis-start"] 79 | 80 | [tasks.redis-start] 81 | description = "Start Redis server using Docker" 82 | run = """ 83 | if ! docker ps | grep -q lazycelery-redis; then 84 | docker run -d --name lazycelery-redis -p 6379:6379 redis:alpine 85 | echo "Redis started on port 6379" 86 | else 87 | echo "Redis already running" 88 | fi 89 | """ 90 | 91 | [tasks.redis-stop] 92 | description = "Stop Redis server" 93 | run = """ 94 | docker stop lazycelery-redis 2>/dev/null || true 95 | docker rm lazycelery-redis 2>/dev/null || true 96 | echo "Redis stopped" 97 | """ 98 | 99 | [tasks.docker-build] 100 | description = "Build Docker image" 101 | run = "docker build -t lazycelery:latest ." 102 | 103 | [tasks.docker-run] 104 | description = "Run Docker container" 105 | run = "docker run -it --rm --network host lazycelery:latest --broker redis://localhost:6379/0" 106 | depends = ["docker-build", "redis-start"] 107 | 108 | [tasks.release] 109 | description = "Create a release build" 110 | run = "cargo build --release --locked" 111 | 112 | [tasks.pre-commit] 113 | description = "Run pre-commit checks" 114 | run = [ 115 | "mise run fmt", 116 | "mise run lint", 117 | "mise run test", 118 | "mise run audit", 119 | "mise run validate-versions" 120 | ] 121 | 122 | [tasks.changelog] 123 | description = "Generate changelog" 124 | run = "git-cliff -o CHANGELOG.md" 125 | 126 | [tasks.version-bump] 127 | description = "Bump version (specify: patch, minor, or major)" 128 | run = """ 129 | if [ -z "$1" ]; then 130 | echo "Usage: mise run version-bump [patch|minor|major]" 131 | exit 1 132 | fi 133 | cargo install cargo-edit 134 | cargo set-version --bump $1 135 | NEW_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') 136 | echo "Bumped version to $NEW_VERSION" 137 | """ 138 | 139 | [tasks.setup] 140 | description = "Setup development environment" 141 | run = [ 142 | "rustup component add rustfmt clippy", 143 | "mise install", 144 | "mise run redis-start", 145 | "echo 'Development environment ready!'" 146 | ] 147 | 148 | [tasks.all] 149 | description = "Run all checks (lint, test, audit)" 150 | depends = ["lint", "test", "audit"] 151 | 152 | [tasks.help] 153 | description = "Show available tasks" 154 | run = "mise tasks" 155 | 156 | [tasks.validate-versions] 157 | description = "Validate version consistency across project files" 158 | run = "python3 scripts/validate-versions.py" 159 | 160 | [tasks.fix-versions] 161 | description = "Fix version inconsistencies automatically" 162 | run = "python3 scripts/validate-versions.py --fix" 163 | 164 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.7.2] - 2025-08-04 2 | 3 | ### 🐛 Bug Fixes 4 | 5 | - Add rustfmt and clippy components to crates.io publish workflow 6 | - Clarify Cargo installation method in README 7 | 8 | ### ⚙️ Miscellaneous Tasks 9 | 10 | - Add success message to rust components installation 11 | ## [0.6.0] - 2025-08-04 12 | 13 | ### 🚀 Features 14 | 15 | - Add CLI subcommands for improved configuration management 16 | ## [0.5.0] - 2025-08-03 17 | 18 | ### 🚀 Features 19 | 20 | - Improve onboarding experience with better error messages and auto-config 21 | ## [0.4.5] - 2025-08-03 22 | 23 | ### 🐛 Bug Fixes 24 | 25 | - Use proper shell syntax for Windows builds 26 | - Use proper shell syntax for Windows builds 27 | ## [0.4.4] - 2025-08-03 28 | 29 | ### 🐛 Bug Fixes 30 | 31 | - Prevent Prepare Release job from running on tag workflows 32 | ## [0.4.3] - 2025-08-03 33 | 34 | ### 🐛 Bug Fixes 35 | 36 | - Add desktop.ini to gitignore for Windows compatibility 37 | ## [0.4.2] - 2025-08-03 38 | 39 | ### 🐛 Bug Fixes 40 | 41 | - Correct workflow release logic to handle version bump scenarios 42 | - Remove complex dependencies from release workflow 43 | - Correct YAML syntax error in release workflow 44 | - Add debugging category to package metadata 45 | - Add write permissions to release workflow 46 | 47 | ### 💼 Other 48 | 49 | - *(deps)* Bump base64 from 0.21.7 to 0.22.1 50 | - *(deps)* Bump softprops/action-gh-release from 1 to 2 51 | - *(deps)* Bump tokio from 1.46.1 to 1.47.0 52 | 53 | ### 🚜 Refactor 54 | 55 | - Simplify release workflow logic for better reliability 56 | ## [0.4.1] - 2025-07-21 57 | 58 | ### 🐛 Bug Fixes 59 | 60 | - Correct pre-commit hook configuration and resolve compilation issues 61 | - Add comprehensive behavioral test coverage for untested modules 62 | - Bump version to 0.4.1 for comprehensive test coverage release 63 | 64 | ### 🚜 Refactor 65 | 66 | - Complete architectural refactoring for better modularity and maintainability 67 | - Implement comprehensive code quality improvements 68 | 69 | ### ⚙️ Miscellaneous Tasks 70 | 71 | - Tidy up project by removing unnecessary files 72 | - Comprehensive project tidy and cleanup 73 | - Enable tracking of test files in git 74 | ## [0.4.0] - 2025-07-20 75 | 76 | ### 🚀 Features 77 | 78 | - *(ui)* Implement comprehensive task details modal 79 | ## [0.3.0] - 2025-07-20 80 | 81 | ### 🚀 Features 82 | 83 | - Implement LazyCelery - Terminal UI for Celery monitoring 84 | - Add Docker support and update project metadata 85 | - Add comprehensive test suite for critical components 86 | - Add CI/CD workflows and GitHub configuration 87 | - Add professional terminal UI screenshots 88 | - Add comprehensive project roadmap 89 | - Complete MVP core actions with queue purge and confirmation dialogs 90 | - Enhance CI/CD workflows with automated releases 91 | - Add pre-commit hooks for code quality checks 92 | - *(release)* Configure automated crates.io publishing 93 | - Configure 100% automatic releases on PR merge 94 | - Configure complete multi-platform package manager automation 95 | - Complete MVP Core Actions v0.2.0 - Queue Purge & Confirmation Dialogs 96 | 97 | ### 🐛 Bug Fixes 98 | 99 | - Resolve clippy warnings and improve code quality 100 | - Resolve mise-action configuration issues 101 | - Resolve CI/CD workflow failures 102 | - Use separate Redis DB for integration tests to avoid CI conflicts 103 | - Use unique task IDs in integration test to avoid interference 104 | - Improve test assertion to focus on our specific test tasks 105 | - Install cargo-audit before running security audit 106 | - *(ci)* Remove unsupported --locked flag from cargo audit command 107 | 108 | ### 💼 Other 109 | 110 | - Migrate from Makefile to mise task runner 111 | - Add debug output to integration test to troubleshoot CI failure 112 | - Add timing delay to ensure data persistence in CI environment 113 | 114 | ### 🚜 Refactor 115 | 116 | - Rename master branch to main 117 | 118 | ### 📚 Documentation 119 | 120 | - Update documentation and add project configuration 121 | - Update changelog with complete project history 122 | - Update CLAUDE.md with comprehensive release automation guidelines [skip ci] 123 | 124 | ### ⚡ Performance 125 | 126 | - Optimize CI workflow for faster execution 127 | - Implement aggressive caching to eliminate crates.io downloads 128 | 129 | ### 🎨 Styling 130 | 131 | - Fix formatting in test file 132 | - Apply rustfmt formatting 133 | 134 | ### ⚙️ Miscellaneous Tasks 135 | 136 | - Update workflows to use mise commands 137 | - Remove unnecessary files 138 | - Clean up redundant workflows and update labeler 139 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to LazyCelery 2 | 3 | Thank you for your interest in contributing to LazyCelery! This document provides guidelines and instructions for contributing. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you agree to abide by our code of conduct: be respectful, inclusive, and constructive. 8 | 9 | ## How to Contribute 10 | 11 | ### Reporting Issues 12 | 13 | - Check if the issue already exists 14 | - Include steps to reproduce 15 | - Include system information (OS, Rust version) 16 | - Include relevant logs or error messages 17 | 18 | ### Submitting Pull Requests 19 | 20 | 1. Fork the repository 21 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 22 | 3. Make your changes 23 | 4. Run tests (`mise run test`) 24 | 5. Run linting (`mise run lint`) 25 | 6. Commit with conventional commits (see below) 26 | 7. Push to your fork 27 | 8. Open a Pull Request 28 | 29 | ### Conventional Commits 30 | 31 | We use [Conventional Commits](https://www.conventionalcommits.org/) for our commit messages: 32 | 33 | - `feat:` New features 34 | - `fix:` Bug fixes 35 | - `docs:` Documentation changes 36 | - `style:` Code style changes (formatting, etc) 37 | - `refactor:` Code refactoring 38 | - `perf:` Performance improvements 39 | - `test:` Test additions or corrections 40 | - `chore:` Maintenance tasks 41 | - `ci:` CI/CD changes 42 | 43 | Example: `feat: add AMQP broker support` 44 | 45 | ### Development Setup 46 | 47 | 1. Install Rust (1.70.0 or later) 48 | 2. Clone the repository 49 | 3. Install mise (task runner): 50 | ```bash 51 | ./scripts/install-mise.sh 52 | ``` 53 | 4. Setup development environment: 54 | ```bash 55 | mise run setup 56 | ``` 57 | 58 | This will: 59 | - Install required Rust components 60 | - Install development tools 61 | - Start Redis using Docker 62 | - Prepare your environment for development 63 | 64 | ### Running Tests 65 | 66 | ```bash 67 | # Run all tests 68 | mise run test 69 | 70 | # Run tests in watch mode 71 | mise run test-watch 72 | 73 | # Run with coverage 74 | mise run coverage 75 | 76 | # Run specific test 77 | cargo test test_worker_creation 78 | ``` 79 | 80 | ### Code Style 81 | 82 | - Run `mise run fmt` to format code 83 | - Run `mise run lint` to check for issues 84 | - Run `mise run check` to verify both formatting and linting 85 | - Follow Rust naming conventions 86 | - Add documentation for public APIs 87 | 88 | ### Pre-commit Checklist 89 | 90 | Run before committing: 91 | ```bash 92 | mise run pre-commit 93 | ``` 94 | 95 | This will run formatting, linting, tests, and security audit. 96 | 97 | ### Pull Request Process 98 | 99 | 1. Update documentation if needed 100 | 2. Add tests for new functionality 101 | 3. Ensure CI passes 102 | 4. Request review from maintainers 103 | 5. Address review feedback 104 | 105 | ## Release Process 106 | 107 | Releases are automated via GitHub Actions: 108 | 109 | 1. Create a version bump PR using the Version Bump workflow 110 | 2. Merge the PR 111 | 3. Create and push a tag: `git tag v1.2.3 && git push origin v1.2.3` 112 | 4. GitHub Actions will create the release 113 | 114 | ## Questions? 115 | 116 | Feel free to open an issue for any questions! -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lazycelery" 3 | version = "0.7.2" 4 | edition = "2021" 5 | authors = ["Francisco Guedes "] 6 | description = "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit" 7 | license = "MIT" 8 | repository = "https://github.com/Fguedes90/lazycelery" 9 | homepage = "https://github.com/Fguedes90/lazycelery" 10 | documentation = "https://docs.rs/lazycelery" 11 | readme = "README.md" 12 | keywords = ["celery", "tui", "terminal", "monitoring", "redis"] 13 | categories = ["command-line-utilities", "development-tools", "debugging"] 14 | rust-version = "1.88.0" 15 | exclude = [ 16 | ".github/", 17 | "tests/", 18 | "benches/", 19 | "docs/", 20 | ".*", 21 | "*.md", 22 | "screenshots/", 23 | "MVP_DEVELOPMENT_PLAN.md", 24 | ] 25 | 26 | [[bin]] 27 | name = "lazycelery" 28 | path = "src/main.rs" 29 | 30 | [dependencies] 31 | # TUI 32 | ratatui = "0.29" 33 | crossterm = "0.27" 34 | 35 | # Async runtime 36 | tokio = { version = "1.47", features = ["full"] } 37 | async-trait = "0.1" 38 | 39 | # Broker clients 40 | redis = { version = "0.24", features = ["tokio-comp"] } 41 | 42 | # Serialization 43 | serde = { version = "1.0", features = ["derive"] } 44 | serde_json = "1.0" 45 | toml = "0.9" 46 | base64 = "0.22" 47 | 48 | # System directories 49 | dirs = "6.0" 50 | 51 | # Time handling 52 | chrono = { version = "0.4", features = ["serde"] } 53 | 54 | # CLI 55 | clap = { version = "4.4", features = ["derive"] } 56 | 57 | # Error handling 58 | thiserror = "1.0" 59 | anyhow = "1.0" 60 | 61 | # Logging 62 | tracing = "0.1" 63 | 64 | [dev-dependencies] 65 | tempfile = "3.8" 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM rust:1.88.0-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache musl-dev pkgconfig openssl-dev 6 | 7 | # Create app directory 8 | WORKDIR /usr/src/lazycelery 9 | 10 | # Copy manifests 11 | COPY Cargo.toml Cargo.lock ./ 12 | 13 | # Copy source code 14 | COPY src ./src 15 | 16 | # Build the application 17 | RUN cargo build --release --target x86_64-unknown-linux-musl 18 | 19 | # Runtime stage 20 | FROM alpine:3.19 21 | 22 | # Install runtime dependencies 23 | RUN apk add --no-cache ca-certificates 24 | 25 | # Copy the binary from builder 26 | COPY --from=builder /usr/src/lazycelery/target/x86_64-unknown-linux-musl/release/lazycelery /usr/local/bin/lazycelery 27 | 28 | # Create non-root user 29 | RUN addgroup -g 1000 lazycelery && \ 30 | adduser -D -u 1000 -G lazycelery lazycelery 31 | 32 | # Switch to non-root user 33 | USER lazycelery 34 | 35 | # Set the entrypoint 36 | ENTRYPOINT ["lazycelery"] 37 | 38 | # Default command (can be overridden) 39 | CMD ["--help"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LazyCelery Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LazyCelery 2 | 3 | [![CI](https://github.com/fguedes90/lazycelery/workflows/CI/badge.svg)](https://github.com/fguedes90/lazycelery/actions/workflows/ci.yml) 4 | [![Release](https://github.com/fguedes90/lazycelery/workflows/Release/badge.svg)](https://github.com/fguedes90/lazycelery/releases) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Crates.io](https://img.shields.io/crates/v/lazycelery.svg)](https://crates.io/crates/lazycelery) 7 | 8 | A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker and lazygit. 9 | 10 | ## Features 11 | 12 | - Real-time worker monitoring 13 | - Queue management with message counts 14 | - Task listing with status tracking 15 | - Search and filter capabilities 16 | - Keyboard-driven interface 17 | - Interactive CLI configuration with subcommands 18 | - Automatic configuration file management 19 | - Helpful error messages and setup guidance 20 | 21 | ## Screenshots 22 | 23 | ### Main Dashboard - Workers View 24 | ![Workers View](screenshots/workers-view.png) 25 | 26 | ### Queue Management 27 | ![Queues View](screenshots/queues-view.png) 28 | 29 | ### Task Monitoring 30 | ![Tasks View](screenshots/tasks-view.png) 31 | 32 | ### Search Mode 33 | ![Search Mode](screenshots/search-mode.png) 34 | 35 | ### Help Screen 36 | ![Help Screen](screenshots/help-screen.png) 37 | 38 | ## Installation 39 | 40 | Choose your preferred installation method: 41 | 42 | ### 🦀 Cargo (Rust package manager) 43 | 44 | ```bash 45 | cargo install lazycelery 46 | ``` 47 | 48 | ### 🍺 Homebrew (macOS/Linux) 49 | 50 | ```bash 51 | brew tap Fguedes90/tap 52 | brew install lazycelery 53 | ``` 54 | 55 | ### 🪣 Scoop (Windows) 56 | 57 | ```bash 58 | scoop bucket add lazycelery https://github.com/Fguedes90/scoop-bucket.git 59 | scoop install lazycelery 60 | ``` 61 | 62 | ### 📥 Binary Download 63 | 64 | Download pre-built binaries from [GitHub Releases](https://github.com/Fguedes90/lazycelery/releases): 65 | 66 | - **Linux x86_64**: `lazycelery-linux-x86_64.tar.gz` 67 | - **macOS x86_64**: `lazycelery-macos-x86_64.tar.gz` 68 | - **macOS ARM64**: `lazycelery-macos-aarch64.tar.gz` 69 | - **Windows x86_64**: `lazycelery-windows-x86_64.zip` 70 | 71 | ### 🔧 From Source 72 | 73 | ```bash 74 | # Clone the repository 75 | git clone https://github.com/fguedes90/lazycelery.git 76 | cd lazycelery 77 | 78 | # Install mise (task runner) 79 | ./scripts/install-mise.sh 80 | 81 | # Setup development environment 82 | mise run setup 83 | 84 | # Build release binary 85 | mise run release 86 | ``` 87 | 88 | ## Quick Start 89 | 90 | ### First Time Setup 91 | 92 | ```bash 93 | # Run interactive setup 94 | lazycelery init 95 | 96 | # Or start with default Redis configuration 97 | lazycelery --broker redis://localhost:6379/0 98 | ``` 99 | 100 | ### Configuration Management 101 | 102 | LazyCelery provides several subcommands to manage your configuration without editing files: 103 | 104 | ```bash 105 | # Initialize configuration with interactive setup 106 | lazycelery init 107 | 108 | # Show current configuration 109 | lazycelery config 110 | 111 | # Update broker URL 112 | lazycelery set-broker redis://localhost:6379/0 113 | 114 | # Update refresh interval (milliseconds) 115 | lazycelery set-refresh 2000 116 | ``` 117 | 118 | ### Running LazyCelery 119 | 120 | ```bash 121 | # Use configured settings 122 | lazycelery 123 | 124 | # Override broker URL 125 | lazycelery --broker redis://localhost:6379/0 126 | 127 | # Use custom config file 128 | lazycelery --config ~/.config/lazycelery/config.toml 129 | ``` 130 | 131 | ### Troubleshooting Connection Issues 132 | 133 | If you encounter connection errors, LazyCelery provides helpful setup instructions: 134 | 135 | 1. **Start Redis** (choose one): 136 | ```bash 137 | # Docker 138 | docker run -d -p 6379:6379 redis 139 | 140 | # macOS 141 | brew services start redis 142 | 143 | # Linux 144 | sudo systemctl start redis 145 | ``` 146 | 147 | 2. **Verify Redis is running**: 148 | ```bash 149 | redis-cli ping 150 | ``` 151 | 152 | 3. **Run LazyCelery**: 153 | ```bash 154 | lazycelery --broker redis://localhost:6379/0 155 | ``` 156 | 157 | ## Keyboard Shortcuts 158 | 159 | - `Tab` - Switch between Workers/Queues/Tasks 160 | - `↑/↓` or `j/k` - Navigate items 161 | - `/` - Search mode 162 | - `?` - Show help 163 | - `q` - Quit 164 | 165 | ## Development 166 | 167 | ### Prerequisites 168 | 169 | - Rust 1.70.0 or later 170 | - Redis (for testing) 171 | - [mise](https://mise.jdx.dev/) (task runner) 172 | 173 | ### Quick Start 174 | 175 | ```bash 176 | # Install mise if you haven't already 177 | ./scripts/install-mise.sh 178 | 179 | # Setup development environment 180 | mise run setup 181 | 182 | # Run with auto-reload 183 | mise run dev 184 | 185 | # Run tests in watch mode 186 | mise run test-watch 187 | ``` 188 | 189 | ### Available Tasks 190 | 191 | ```bash 192 | mise tasks # Show all available tasks 193 | mise run build # Build release binary 194 | mise run dev # Run with auto-reload 195 | mise run test # Run tests 196 | mise run lint # Run linter 197 | mise run fmt # Format code 198 | mise run audit # Security audit 199 | mise run coverage # Generate coverage report 200 | mise run docs # Generate documentation 201 | ``` 202 | 203 | ### Pre-commit Checks 204 | 205 | Before committing, run: 206 | 207 | ```bash 208 | mise run pre-commit 209 | ``` 210 | 211 | This runs formatting, linting, tests, and security audit. 212 | 213 | ## Contributing 214 | 215 | See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 216 | 217 | ## License 218 | 219 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 220 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # LazyCelery Project Roadmap 2 | 3 | ## Project Status 4 | - **Current Phase**: MVP Core Features Complete (v0.2.0) 5 | - **Architecture**: Fully implemented with comprehensive test coverage 6 | - **Real Celery Integration**: ✅ Complete with Redis broker 7 | - **Infrastructure**: CI/CD, Docker, documentation ready 8 | - **Next Major Milestone**: Enhanced Monitoring Features (v0.3.0) 9 | 10 | ## MVP Core Features (v0.2.0) ✅ COMPLETE 11 | 12 | ### Worker Foundation ✅ COMPLETE 13 | - [x] Implement async Redis broker client with connection pooling 14 | - [x] Build worker discovery and status monitoring from real Celery data 15 | - [x] Create basic TUI layout with worker list widget 16 | - [x] Add real-time worker status updates (1-second intervals) 17 | - [x] **BONUS**: Real worker statistics from task metadata analysis 18 | 19 | ### Queue & Task Basics ✅ COMPLETE 20 | - [x] Queue monitoring with message counts and consumption rates 21 | - [x] Task listing with status filtering (pending/active/success/failure/retry/revoked) 22 | - [x] Basic task details view (args, kwargs, timestamps, results, tracebacks) 23 | - [x] Search and filter functionality for tasks 24 | - [x] **BONUS**: Dynamic queue discovery from kombu bindings 25 | - [x] **BONUS**: Real Celery task metadata parsing with timestamp support 26 | 27 | ### Core Actions ✅ COMPLETE 28 | - [x] Task retry functionality for failed tasks with proper Celery protocol 29 | - [x] Task revocation for running tasks with revoked set management 30 | - [x] Configuration file support (TOML format) 31 | - [x] Queue purge operations with confirmation dialogs 32 | 33 | ### Enhanced UX ✅ COMPLETE 34 | - [x] Advanced navigation (vim-style keybindings: j/k/h/l/g/G) 35 | - [x] Help system and keyboard shortcut overlays (? key) 36 | - [x] Error handling with user-friendly messages 37 | - [x] Basic metrics display (success rates, queue lengths, worker stats) 38 | - [x] **BONUS**: Search mode with live filtering 39 | - [x] **BONUS**: Tab-based navigation between workers/tasks/queues 40 | 41 | ### Polish & Reliability ✅ COMPLETE 42 | - [x] Connection resilience (auto-reconnect) via Redis multiplexed connection 43 | - [x] Performance optimization for large datasets (limit to 100 tasks for UI responsiveness) 44 | - [x] Memory usage optimization (efficient async operations) 45 | - [x] Comprehensive error recovery (custom error types with thiserror) 46 | - [x] **BONUS**: Comprehensive test suite (75+ tests including stress tests) 47 | - [x] **BONUS**: Base64 decoding for Celery task message bodies 48 | 49 | ## 🎉 v0.2.0 COMPLETE - Major Technical Achievements 50 | 51 | ### Real Celery Protocol Integration 52 | - ✅ **Worker Discovery**: Real worker detection from task metadata and queue message origins 53 | - ✅ **Task Management**: Functional retry/revoke operations following Celery protocol 54 | - ✅ **Queue Discovery**: Dynamic queue detection from kombu bindings 55 | - ✅ **Data Parsing**: Full compatibility with Redis-based Celery broker 56 | 57 | ### Testing & Quality Assurance 58 | - ✅ **75+ Tests**: Comprehensive coverage including unit, integration, and stress tests 59 | - ✅ **Real Celery Simulation**: Tests with actual Celery message formats and Redis structures 60 | - ✅ **Performance Validation**: Stress tested with 500+ tasks 61 | - ✅ **Error Resilience**: Robust handling of malformed data and edge cases 62 | 63 | ### UI/UX Excellence 64 | - ✅ **Terminal UI**: Professional ratatui-based interface with 10 FPS optimization 65 | - ✅ **Vim-style Navigation**: Intuitive keyboard controls for power users 66 | - ✅ **Real-time Updates**: Live monitoring with 1-second refresh intervals 67 | - ✅ **Search & Filter**: Advanced filtering capabilities across all data types 68 | 69 | ### Architecture & Performance 70 | - ✅ **Async Foundation**: Fully async Tokio-based architecture 71 | - ✅ **Efficient Redis**: Multiplexed connections with connection pooling 72 | - ✅ **Memory Optimized**: Smart pagination and data limiting 73 | - ✅ **Production Ready**: Error handling, logging, and configuration management 74 | 75 | ## Phase 2: Enhanced Monitoring (Next Priority) 76 | 77 | ### Immediate Next Steps (v0.3.0) 78 | - [ ] AMQP/RabbitMQ broker support (extend beyond Redis) 79 | - [ ] Enhanced task name display (extract from queue messages) 80 | - [ ] Real-time task progress indicators 81 | - [ ] Worker heartbeat detection via inspect commands 82 | 83 | ### Advanced Worker Management (v0.4.0) 84 | - [ ] Worker control (start/stop/restart) with proper permissions 85 | - [ ] Worker pool scaling controls 86 | - [ ] Worker resource monitoring (CPU, memory) 87 | - [ ] Worker heartbeat visualization 88 | - [ ] Historical worker performance data 89 | 90 | ### Enhanced Queue Features 91 | - [ ] Priority queue visualization 92 | - [ ] Queue routing visualization 93 | - [ ] Dead letter queue support 94 | - [ ] Queue performance analytics 95 | - [ ] Bulk message operations 96 | 97 | ### Task Enhancements 98 | - [ ] Task dependency visualization (chains, groups, chords) 99 | - [ ] Task result backend support 100 | - [ ] Bulk task operations 101 | - [ ] Task scheduling preview (for Celery Beat) 102 | - [ ] Export task data (CSV, JSON) 103 | 104 | ## Phase 3: Debugging & Analysis Tools (v0.5.0+) 105 | 106 | ### Basic Debugging 107 | - [ ] Task execution timeline 108 | - [ ] Parent-child task relationships 109 | - [ ] Basic error grouping 110 | - [ ] Task replay functionality 111 | - [ ] Enhanced search (regex, date ranges) 112 | 113 | ### Performance Analysis 114 | - [ ] Task duration heatmaps 115 | - [ ] Worker load distribution 116 | - [ ] Queue throughput graphs 117 | - [ ] SLA monitoring 118 | - [ ] Performance trends over time 119 | 120 | ### Integration Features 121 | - [ ] Flower API compatibility 122 | - [ ] Prometheus metrics export 123 | - [ ] Webhook notifications 124 | - [ ] REST API for external tools 125 | 126 | ## Phase 4: Advanced Features (v0.7.0+) 127 | 128 | ### Distributed Tracing (If Needed) 129 | - [ ] OpenTelemetry integration 130 | - [ ] Basic span visualization 131 | - [ ] Cross-service correlation 132 | - [ ] Trace sampling 133 | 134 | ### Advanced Error Analysis 135 | - [ ] Error pattern detection 136 | - [ ] Similar error clustering 137 | - [ ] Error frequency trends 138 | - [ ] Root cause suggestions 139 | 140 | ### Multi-Environment Support 141 | - [ ] Multiple broker connections 142 | - [ ] Environment switching 143 | - [ ] Cluster view 144 | - [ ] Cross-environment task search 145 | 146 | ## Phase 5: Enterprise Features (v0.9.0+) 147 | 148 | ### Security & Compliance 149 | - [ ] Audit logging 150 | - [ ] Data masking for sensitive info 151 | - [ ] Compliance reporting 152 | 153 | ### Automation 154 | - [ ] Alert rules 155 | - [ ] Auto-retry policies 156 | - [ ] Task routing rules 157 | - [ ] Scheduled reports 158 | 159 | ### Platform Support 160 | - [ ] Homebrew formula 161 | - [ ] Package managers (apt, yum, pacman) 162 | 163 | --- 164 | 165 | ## 📊 Current Status Summary 166 | 167 | | Component | Status | Progress | Notes | 168 | |-----------|--------|----------|-------| 169 | | **Core Architecture** | ✅ Complete | 100% | Async Tokio + Ratatui | 170 | | **Redis Broker** | ✅ Complete | 100% | Real Celery protocol integration | 171 | | **Worker Discovery** | ✅ Complete | 100% | From task metadata analysis | 172 | | **Task Management** | ✅ Complete | 100% | Retry/Revoke with Celery protocol | 173 | | **Queue Monitoring** | ✅ Complete | 100% | Dynamic discovery + real-time data | 174 | | **Terminal UI** | ✅ Complete | 100% | Professional TUI with vim navigation | 175 | | **Testing Suite** | ✅ Complete | 100% | 75+ tests including stress/integration | 176 | | **AMQP Support** | ❌ Pending | 0% | Redis-only currently | 177 | | **Advanced Features** | ❌ Pending | 0% | Worker control, analytics, etc. | 178 | 179 | ### 🎯 **v0.2.0 Achievement** 180 | The project has successfully completed all MVP core features and demonstrates full Celery protocol compatibility with Redis broker. 181 | 182 | ### 🛣️ **Path to v1.0.0** 183 | - **v0.3.0**: AMQP support + Enhanced monitoring 184 | - **v0.4.0**: Advanced worker management 185 | - **v0.5.0**: Debugging and analysis tools 186 | - **v0.6.0**: Performance optimization and polish 187 | - **v0.7.0**: Advanced features and integrations 188 | - **v0.8.0**: Enterprise features 189 | - **v0.9.0**: Pre-release stability and documentation 190 | - **v1.0.0**: Production-ready release 191 | 192 | ### 🚀 **Next Development Focus (v0.3.0)** 193 | 1. AMQP/RabbitMQ broker support for broader compatibility 194 | 2. Queue purge operations and enhanced task name display 195 | 3. Real-time task progress indicators and worker heartbeat detection 196 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # cargo-deny configuration 2 | 3 | [graph] 4 | # When creating the dependency graph used as the source of truth when checks are 5 | # executed, this field can be used to prune crates from the graph, removing them 6 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 7 | # is pruned from the graph, all of its dependencies will also be pruned unless 8 | # they are connected to another crate in the graph that hasn't been pruned, 9 | # so it should be used with care. The identifiers are [Package ID Specifications] 10 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 11 | targets = [] 12 | 13 | [licenses] 14 | # List of explicitly allowed licenses 15 | allow = [ 16 | "MIT", 17 | "Apache-2.0", 18 | "Apache-2.0 WITH LLVM-exception", 19 | "BSD-2-Clause", 20 | "BSD-3-Clause", 21 | "ISC", 22 | "Unicode-DFS-2016", 23 | "CC0-1.0", 24 | ] 25 | 26 | # The confidence threshold for detecting a license from license text. 27 | # Possible values are numbers between 0.0 and 1.0 28 | confidence-threshold = 0.8 29 | 30 | [bans] 31 | # Lint level for when multiple versions of the same crate are detected 32 | multiple-versions = "warn" 33 | # Lint level for when a crate marked as 'deny' is detected 34 | deny = "warn" 35 | # Lint level for when a crate marked as 'warn' is detected 36 | warn = "warn" 37 | # Lint level for when a crate marked as 'allow' is detected 38 | allow = "warn" 39 | 40 | # List of explicitly disallowed crates 41 | deny = [ 42 | # Example: { name = "openssl" }, # We prefer rustls 43 | ] 44 | 45 | # Skip certain crates when doing duplicate detection. 46 | skip = [] 47 | 48 | # Similarly named crates that are allowed to coexist 49 | skip-tree = [] 50 | 51 | [advisories] 52 | # The path where the advisory database is cloned/fetched into 53 | db-path = "~/.cargo/advisory-db" 54 | # The url(s) of the advisory databases to use 55 | db-urls = ["https://github.com/rustsec/advisory-db"] 56 | # The lint level for unmaintained crates 57 | unmaintained = "warn" 58 | # The lint level for crates that have been yanked from their source registry 59 | yanked = "warn" 60 | # The lint level for crates with security notices 61 | notice = "warn" 62 | # A list of advisory IDs to ignore 63 | ignore = [] 64 | 65 | [sources] 66 | # Lint level for what to happen when a crate from a crate registry that is not in the allow list is detected 67 | unknown-registry = "warn" 68 | # Lint level for what to happen when a crate from a git repository that is not in the allow list is detected 69 | unknown-git = "warn" 70 | # List of allowed crate registries 71 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 72 | # List of allowed Git repositories 73 | allow-git = [] -------------------------------------------------------------------------------- /examples/config.toml: -------------------------------------------------------------------------------- 1 | # LazyCelery Configuration Example 2 | 3 | [broker] 4 | # Redis broker URL 5 | url = "redis://localhost:6379/0" 6 | 7 | # Connection timeout in seconds 8 | timeout = 30 9 | 10 | # Number of retry attempts for failed connections 11 | retry_attempts = 3 12 | 13 | [ui] 14 | # Data refresh interval in milliseconds 15 | refresh_interval = 1000 16 | 17 | # UI theme (currently only "dark" is supported) 18 | theme = "dark" 19 | -------------------------------------------------------------------------------- /packaging/.gitignore: -------------------------------------------------------------------------------- 1 | # Package manager working files 2 | *.pkg.tar.xz 3 | *.deb 4 | *.rpm 5 | *.snap 6 | *.nupkg 7 | *.zip 8 | *.tar.gz 9 | 10 | # Temporary build files 11 | build/ 12 | dist/ 13 | target/ 14 | 15 | # Checksums and metadata 16 | checksums.txt 17 | *.SRCINFO 18 | *.buildinfo 19 | 20 | # Package manager specific 21 | chocolatey/tools/*.exe 22 | scoop/generated/ 23 | homebrew/generated/ -------------------------------------------------------------------------------- /packaging/README.md: -------------------------------------------------------------------------------- 1 | # Package Management 2 | 3 | This directory contains packaging configurations for various package managers, all generated from the canonical metadata in `Cargo.toml`. 4 | 5 | ## Template System 6 | 7 | To avoid metadata duplication and ensure consistency across all package managers, we use a template generation system: 8 | 9 | ### Usage 10 | 11 | ```bash 12 | # Generate all package files from Cargo.toml metadata 13 | python3 packaging/generate_packages.py 14 | ``` 15 | 16 | This script automatically: 17 | - Extracts metadata from `Cargo.toml` (version, description, author, etc.) 18 | - Generates consistent package configurations for all platforms 19 | - Updates version numbers across all packaging formats 20 | 21 | ### Supported Package Managers 22 | 23 | | Package Manager | File | Platform | 24 | |-----------------|------|----------| 25 | | **AUR Source** | `aur/PKGBUILD` | Arch Linux (build from source) | 26 | | **AUR Binary** | `aur/PKGBUILD-bin` | Arch Linux (pre-built binary) | 27 | | **Homebrew** | `homebrew/lazycelery.rb` | macOS | 28 | | **Chocolatey** | `chocolatey/lazycelery.nuspec` | Windows | 29 | | **Scoop** | `scoop/lazycelery.json` | Windows | 30 | | **Snap** | `snap/snapcraft.yaml` | Ubuntu/Linux | 31 | 32 | ### Manual Updates Required 33 | 34 | After running the generator, you may need to manually update: 35 | 36 | 1. **SHA256 Hashes**: Update `PLACEHOLDER_SHA256` values after creating release artifacts 37 | 2. **Platform-specific details**: Adjust build dependencies or installation steps if needed 38 | 3. **Release notes**: Add version-specific changelog entries 39 | 40 | ### Benefits 41 | 42 | - ✅ **Single Source of Truth**: All metadata comes from `Cargo.toml` 43 | - ✅ **Version Consistency**: No more mismatched versions across packages 44 | - ✅ **Reduced Duplication**: Description, license, repository URL only defined once 45 | - ✅ **Easy Updates**: Change once in `Cargo.toml`, regenerate all packages 46 | - ✅ **CI Integration**: Can be automated in release workflows 47 | 48 | ### Integration with CI/CD 49 | 50 | The package generation can be integrated into the automated release workflow: 51 | 52 | ```yaml 53 | - name: Generate package files 54 | run: python3 packaging/generate_packages.py 55 | 56 | - name: Update SHA256 hashes 57 | run: | 58 | # Calculate and update hashes for release artifacts 59 | # This would be done after building release binaries 60 | ``` 61 | 62 | ### Development Workflow 63 | 64 | 1. **Update version** in `Cargo.toml` 65 | 2. **Run generator**: `python3 packaging/generate_packages.py` 66 | 3. **Commit changes**: All package files are now updated consistently 67 | 4. **Create release**: CI will use the updated package configurations 68 | 69 | This approach ensures that package maintainers always have accurate, up-to-date configurations without manual synchronization across multiple files. -------------------------------------------------------------------------------- /packaging/homebrew/lazycelery.rb: -------------------------------------------------------------------------------- 1 | class Lazycelery < Formula 2 | desc "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit" 3 | homepage "https://github.com/Fguedes90/lazycelery" 4 | version "0.4.5" 5 | license "MIT" 6 | 7 | on_macos do 8 | if Hardware::CPU.arm? 9 | url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-macos-aarch64.tar.gz" 10 | sha256 "97b1ad921373e1f8b71a309be1bc67dc47ec1a555b3c7f8ab49211e9c56b7352" 11 | else 12 | url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-macos-x86_64.tar.gz" 13 | sha256 "4a6fc2e614968860c259973427ff02d8431b96f091640bf1f1c146d2c2a8f726" 14 | end 15 | end 16 | 17 | on_linux do 18 | url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-linux-x86_64.tar.gz" 19 | sha256 "71201741d7d920ea417491bf490d4f33006e3f3da2ff9139e4f73019b6145472" 20 | end 21 | 22 | def install 23 | bin.install "lazycelery" 24 | end 25 | 26 | test do 27 | assert_match "lazycelery", shell_output("#{bin}/lazycelery --help") 28 | end 29 | end -------------------------------------------------------------------------------- /packaging/scoop/lazycelery.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.4.5", 3 | "description": "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit", 4 | "homepage": "https://github.com/Fguedes90/lazycelery", 5 | "license": "MIT", 6 | "architecture": { 7 | "64bit": { 8 | "url": "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-windows-x86_64.zip", 9 | "hash": "PLACEHOLDER_SHA256", 10 | "extract_dir": "" 11 | } 12 | }, 13 | "bin": "lazycelery.exe", 14 | "checkver": { 15 | "github": "https://github.com/Fguedes90/lazycelery" 16 | }, 17 | "autoupdate": { 18 | "architecture": { 19 | "64bit": { 20 | "url": "https://github.com/Fguedes90/lazycelery/releases/download/v$version/lazycelery-windows-x86_64.zip" 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Rust formatting configuration (stable channel only) 2 | 3 | edition = "2021" 4 | max_width = 100 5 | hard_tabs = false 6 | tab_spaces = 4 7 | newline_style = "Unix" 8 | use_field_init_shorthand = true 9 | use_try_shorthand = true 10 | reorder_imports = true 11 | reorder_modules = true 12 | remove_nested_parens = true 13 | match_arm_leading_pipes = "Never" 14 | merge_derives = true 15 | use_small_heuristics = "Default" -------------------------------------------------------------------------------- /screenshots/help-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fguedes90/lazycelery/eb3c0533c449f1b1468fc4ee4acdc71d9ebc214e/screenshots/help-screen.png -------------------------------------------------------------------------------- /screenshots/queues-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fguedes90/lazycelery/eb3c0533c449f1b1468fc4ee4acdc71d9ebc214e/screenshots/queues-view.png -------------------------------------------------------------------------------- /screenshots/search-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fguedes90/lazycelery/eb3c0533c449f1b1468fc4ee4acdc71d9ebc214e/screenshots/search-mode.png -------------------------------------------------------------------------------- /screenshots/tasks-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fguedes90/lazycelery/eb3c0533c449f1b1468fc4ee4acdc71d9ebc214e/screenshots/tasks-view.png -------------------------------------------------------------------------------- /screenshots/workers-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fguedes90/lazycelery/eb3c0533c449f1b1468fc4ee4acdc71d9ebc214e/screenshots/workers-view.png -------------------------------------------------------------------------------- /src/app/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::{AppState, PendingAction, Tab}; 2 | use crate::error::AppError; 3 | 4 | impl AppState { 5 | /// Refresh all data from the broker 6 | pub async fn refresh_data(&mut self) -> Result<(), AppError> { 7 | let (workers_result, tasks_result, queues_result) = { 8 | let broker = self.broker.lock().await; 9 | 10 | // Fetch all data in parallel 11 | tokio::join!( 12 | broker.get_workers(), 13 | broker.get_tasks(), 14 | broker.get_queues() 15 | ) 16 | }; 17 | 18 | self.workers = workers_result?; 19 | self.tasks = tasks_result?; 20 | self.queues = queues_result?; 21 | 22 | // Validate selections after data refresh 23 | self.validate_selections(); 24 | 25 | Ok(()) 26 | } 27 | 28 | /// Execute the pending action (purge queue, retry task, or revoke task) 29 | pub async fn execute_pending_action(&mut self) -> Result<(), AppError> { 30 | if let Some(action) = self.pending_action.take() { 31 | let message = { 32 | let broker = self.broker.lock().await; 33 | 34 | match &action { 35 | PendingAction::PurgeQueue(queue_name) => { 36 | match broker.purge_queue(queue_name).await { 37 | Ok(count) => { 38 | format!("Purged {count} messages from queue '{queue_name}'") 39 | } 40 | Err(e) => format!("Failed to purge queue '{queue_name}': {e}"), 41 | } 42 | } 43 | PendingAction::RetryTask(task_id) => match broker.retry_task(task_id).await { 44 | Ok(_) => format!("Task '{task_id}' marked for retry"), 45 | Err(e) => format!("Failed to retry task '{task_id}': {e}"), 46 | }, 47 | PendingAction::RevokeTask(task_id) => match broker.revoke_task(task_id).await { 48 | Ok(_) => format!("Task '{task_id}' revoked"), 49 | Err(e) => format!("Failed to revoke task '{task_id}': {e}"), 50 | }, 51 | } 52 | }; 53 | 54 | self.set_status_message(message); 55 | } 56 | 57 | self.hide_confirmation_dialog(); 58 | Ok(()) 59 | } 60 | 61 | /// Initiate queue purge action with confirmation dialog 62 | pub fn initiate_purge_queue(&mut self) { 63 | if !self.queues.is_empty() && self.selected_tab == Tab::Queues { 64 | let queue = &self.queues[self.selected_queue]; 65 | let message = format!( 66 | "Are you sure you want to purge all {} messages from queue '{}'?", 67 | queue.length, queue.name 68 | ); 69 | self.show_confirmation_dialog(message, PendingAction::PurgeQueue(queue.name.clone())); 70 | } 71 | } 72 | 73 | /// Initiate task retry action with confirmation dialog 74 | pub fn initiate_retry_task(&mut self) { 75 | if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks { 76 | let filtered_tasks = self.get_filtered_tasks(); 77 | if self.selected_task < filtered_tasks.len() { 78 | let task = filtered_tasks[self.selected_task]; 79 | let message = format!("Are you sure you want to retry task '{}'?", task.id); 80 | self.show_confirmation_dialog(message, PendingAction::RetryTask(task.id.clone())); 81 | } 82 | } 83 | } 84 | 85 | /// Initiate task revoke action with confirmation dialog 86 | pub fn initiate_revoke_task(&mut self) { 87 | if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks { 88 | let filtered_tasks = self.get_filtered_tasks(); 89 | if self.selected_task < filtered_tasks.len() { 90 | let task = filtered_tasks[self.selected_task]; 91 | let message = format!("Are you sure you want to revoke task '{}'?", task.id); 92 | self.show_confirmation_dialog(message, PendingAction::RevokeTask(task.id.clone())); 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | //! Application module providing state management and business logic for LazyCelery. 2 | //! 3 | //! This module is organized into separate concerns: 4 | //! - `state`: Core application state, navigation, and UI state management 5 | //! - `actions`: Business logic for broker operations and user actions 6 | 7 | mod actions; 8 | mod state; 9 | 10 | // Re-export the main types for convenience 11 | pub use state::{AppState, Tab}; 12 | 13 | // Create a type alias for backward compatibility 14 | pub type App = AppState; 15 | -------------------------------------------------------------------------------- /src/app/state.rs: -------------------------------------------------------------------------------- 1 | use crate::broker::Broker; 2 | use crate::models::{Queue, Task, Worker}; 3 | use std::sync::Arc; 4 | use tokio::sync::Mutex; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 7 | pub enum Tab { 8 | Workers, 9 | Queues, 10 | Tasks, 11 | } 12 | 13 | #[derive(Debug, Clone)] 14 | pub enum PendingAction { 15 | PurgeQueue(String), 16 | RetryTask(String), 17 | RevokeTask(String), 18 | } 19 | 20 | pub struct AppState { 21 | // Data state 22 | pub workers: Vec, 23 | pub tasks: Vec, 24 | pub queues: Vec, 25 | 26 | // Navigation state 27 | pub selected_tab: Tab, 28 | pub selected_worker: usize, 29 | pub selected_task: usize, 30 | pub selected_queue: usize, 31 | 32 | // UI state 33 | pub should_quit: bool, 34 | pub show_help: bool, 35 | pub search_query: String, 36 | pub is_searching: bool, 37 | 38 | // Dialog state 39 | pub show_confirmation: bool, 40 | pub confirmation_message: String, 41 | pub pending_action: Option, 42 | pub status_message: String, 43 | 44 | // Task details state 45 | pub show_task_details: bool, 46 | pub selected_task_details: Option, 47 | 48 | // Broker 49 | pub(crate) broker: Arc>>, 50 | } 51 | 52 | impl AppState { 53 | pub fn new(broker: Box) -> Self { 54 | Self { 55 | workers: Vec::new(), 56 | tasks: Vec::new(), 57 | queues: Vec::new(), 58 | selected_tab: Tab::Workers, 59 | should_quit: false, 60 | selected_worker: 0, 61 | selected_task: 0, 62 | selected_queue: 0, 63 | show_help: false, 64 | search_query: String::new(), 65 | is_searching: false, 66 | show_confirmation: false, 67 | confirmation_message: String::new(), 68 | pending_action: None, 69 | status_message: String::new(), 70 | show_task_details: false, 71 | selected_task_details: None, 72 | broker: Arc::new(Mutex::new(broker)), 73 | } 74 | } 75 | 76 | // Tab navigation 77 | pub fn next_tab(&mut self) { 78 | self.selected_tab = match self.selected_tab { 79 | Tab::Workers => Tab::Queues, 80 | Tab::Queues => Tab::Tasks, 81 | Tab::Tasks => Tab::Workers, 82 | }; 83 | } 84 | 85 | pub fn previous_tab(&mut self) { 86 | self.selected_tab = match self.selected_tab { 87 | Tab::Workers => Tab::Tasks, 88 | Tab::Queues => Tab::Workers, 89 | Tab::Tasks => Tab::Queues, 90 | }; 91 | } 92 | 93 | // Item selection 94 | pub fn select_next(&mut self) { 95 | match self.selected_tab { 96 | Tab::Workers => { 97 | if !self.workers.is_empty() { 98 | self.selected_worker = (self.selected_worker + 1) % self.workers.len(); 99 | } 100 | } 101 | Tab::Tasks => { 102 | let filtered_count = self.get_filtered_tasks().len(); 103 | if filtered_count > 0 { 104 | self.selected_task = (self.selected_task + 1) % filtered_count; 105 | } 106 | } 107 | Tab::Queues => { 108 | if !self.queues.is_empty() { 109 | self.selected_queue = (self.selected_queue + 1) % self.queues.len(); 110 | } 111 | } 112 | } 113 | } 114 | 115 | pub fn select_previous(&mut self) { 116 | match self.selected_tab { 117 | Tab::Workers => { 118 | if !self.workers.is_empty() { 119 | self.selected_worker = if self.selected_worker == 0 { 120 | self.workers.len() - 1 121 | } else { 122 | self.selected_worker - 1 123 | }; 124 | } 125 | } 126 | Tab::Tasks => { 127 | let filtered_count = self.get_filtered_tasks().len(); 128 | if filtered_count > 0 { 129 | self.selected_task = if self.selected_task == 0 { 130 | filtered_count - 1 131 | } else { 132 | self.selected_task - 1 133 | }; 134 | } 135 | } 136 | Tab::Queues => { 137 | if !self.queues.is_empty() { 138 | self.selected_queue = if self.selected_queue == 0 { 139 | self.queues.len() - 1 140 | } else { 141 | self.selected_queue - 1 142 | }; 143 | } 144 | } 145 | } 146 | } 147 | 148 | // UI state management 149 | pub fn toggle_help(&mut self) { 150 | self.show_help = !self.show_help; 151 | } 152 | 153 | pub fn start_search(&mut self) { 154 | self.is_searching = true; 155 | self.search_query.clear(); 156 | } 157 | 158 | pub fn stop_search(&mut self) { 159 | self.is_searching = false; 160 | self.search_query.clear(); 161 | // Reset selection when search is cleared 162 | if self.selected_tab == Tab::Tasks { 163 | self.selected_task = 0; 164 | } 165 | } 166 | 167 | // Task filtering 168 | pub fn get_filtered_tasks(&self) -> Vec<&Task> { 169 | if self.search_query.is_empty() { 170 | self.tasks.iter().collect() 171 | } else { 172 | self.tasks 173 | .iter() 174 | .filter(|task| { 175 | task.name 176 | .to_lowercase() 177 | .contains(&self.search_query.to_lowercase()) 178 | || task 179 | .id 180 | .to_lowercase() 181 | .contains(&self.search_query.to_lowercase()) 182 | }) 183 | .collect() 184 | } 185 | } 186 | 187 | // Dialog management 188 | pub fn show_confirmation_dialog(&mut self, message: String, action: PendingAction) { 189 | self.confirmation_message = message; 190 | self.pending_action = Some(action); 191 | self.show_confirmation = true; 192 | } 193 | 194 | pub fn hide_confirmation_dialog(&mut self) { 195 | self.show_confirmation = false; 196 | self.confirmation_message.clear(); 197 | self.pending_action = None; 198 | } 199 | 200 | // Status message management 201 | pub fn set_status_message(&mut self, message: String) { 202 | self.status_message = message; 203 | } 204 | 205 | pub fn clear_status_message(&mut self) { 206 | self.status_message.clear(); 207 | } 208 | 209 | // Task details management 210 | pub fn show_task_details(&mut self) { 211 | if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks { 212 | let filtered_tasks = self.get_filtered_tasks(); 213 | if self.selected_task < filtered_tasks.len() { 214 | let task = filtered_tasks[self.selected_task]; 215 | self.selected_task_details = Some(task.clone()); 216 | self.show_task_details = true; 217 | } 218 | } 219 | } 220 | 221 | pub fn hide_task_details(&mut self) { 222 | self.show_task_details = false; 223 | self.selected_task_details = None; 224 | } 225 | 226 | // Data validation after refresh 227 | pub fn validate_selections(&mut self) { 228 | // Ensure selection indices are valid 229 | if self.selected_worker >= self.workers.len() && !self.workers.is_empty() { 230 | self.selected_worker = self.workers.len() - 1; 231 | } 232 | if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() { 233 | self.selected_task = self.tasks.len() - 1; 234 | } 235 | if self.selected_queue >= self.queues.len() && !self.queues.is_empty() { 236 | self.selected_queue = self.queues.len() - 1; 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/broker/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod redis; 2 | 3 | use crate::error::BrokerError; 4 | use crate::models::{Queue, Task, Worker}; 5 | use async_trait::async_trait; 6 | 7 | #[async_trait] 8 | #[allow(dead_code)] 9 | pub trait Broker: Send + Sync { 10 | async fn connect(url: &str) -> Result 11 | where 12 | Self: Sized; 13 | 14 | async fn get_workers(&self) -> Result, BrokerError>; 15 | async fn get_tasks(&self) -> Result, BrokerError>; 16 | async fn get_queues(&self) -> Result, BrokerError>; 17 | async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError>; 18 | async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError>; 19 | async fn purge_queue(&self, queue_name: &str) -> Result; 20 | } 21 | -------------------------------------------------------------------------------- /src/broker/redis/facade.rs: -------------------------------------------------------------------------------- 1 | use crate::broker::redis::operations::TaskOperations; 2 | use crate::broker::redis::pool::ConnectionPool; 3 | use crate::broker::redis::protocol::ProtocolParser; 4 | use crate::error::BrokerError; 5 | use crate::models::{Queue, Task, Worker}; 6 | use std::sync::Arc; 7 | use tracing::{debug, error, info, instrument, warn}; 8 | 9 | /// BrokerFacade provides a clean, high-level interface for Redis broker operations. 10 | /// It encapsulates connection management, error handling, and operation complexity. 11 | pub struct BrokerFacade { 12 | pool: Arc, 13 | } 14 | 15 | impl BrokerFacade { 16 | pub async fn new(url: &str) -> Result { 17 | info!( 18 | "Creating new Redis broker facade for URL: {}", 19 | url.split('@').next_back().unwrap_or("hidden") 20 | ); 21 | 22 | let pool = ConnectionPool::new(url, Some(10)).await.map_err(|e| { 23 | error!("Failed to create connection pool: {}", e); 24 | e 25 | })?; 26 | 27 | info!("Redis broker facade created successfully"); 28 | 29 | Ok(Self { 30 | pool: Arc::new(pool), 31 | }) 32 | } 33 | 34 | /// Get all workers with comprehensive error handling and logging 35 | #[instrument(skip(self), name = "get_workers")] 36 | pub async fn get_workers(&self) -> Result, BrokerError> { 37 | debug!("Fetching workers from Redis"); 38 | 39 | let connection = self.get_pooled_connection("get_workers").await?; 40 | 41 | match ProtocolParser::parse_workers(&connection).await { 42 | Ok(workers) => { 43 | info!("Successfully retrieved {} workers", workers.len()); 44 | debug!( 45 | "Workers: {:?}", 46 | workers.iter().map(|w| &w.hostname).collect::>() 47 | ); 48 | Ok(workers) 49 | } 50 | Err(e) => { 51 | error!("Failed to parse workers: {}", e); 52 | Err(self.add_operation_context(e, "get_workers")) 53 | } 54 | } 55 | } 56 | 57 | /// Get all tasks with comprehensive error handling and logging 58 | #[instrument(skip(self), name = "get_tasks")] 59 | pub async fn get_tasks(&self) -> Result, BrokerError> { 60 | debug!("Fetching tasks from Redis"); 61 | 62 | let connection = self.get_pooled_connection("get_tasks").await?; 63 | 64 | match ProtocolParser::parse_tasks(&connection).await { 65 | Ok(tasks) => { 66 | info!("Successfully retrieved {} tasks", tasks.len()); 67 | debug!( 68 | "Task statuses: {:?}", 69 | tasks.iter().map(|t| &t.status).collect::>() 70 | ); 71 | Ok(tasks) 72 | } 73 | Err(e) => { 74 | error!("Failed to parse tasks: {}", e); 75 | Err(self.add_operation_context(e, "get_tasks")) 76 | } 77 | } 78 | } 79 | 80 | /// Get all queues with comprehensive error handling and logging 81 | #[instrument(skip(self), name = "get_queues")] 82 | pub async fn get_queues(&self) -> Result, BrokerError> { 83 | debug!("Fetching queues from Redis"); 84 | 85 | let connection = self.get_pooled_connection("get_queues").await?; 86 | 87 | match ProtocolParser::parse_queues(&connection).await { 88 | Ok(queues) => { 89 | info!("Successfully retrieved {} queues", queues.len()); 90 | debug!( 91 | "Queue names: {:?}", 92 | queues.iter().map(|q| &q.name).collect::>() 93 | ); 94 | Ok(queues) 95 | } 96 | Err(e) => { 97 | error!("Failed to parse queues: {}", e); 98 | Err(self.add_operation_context(e, "get_queues")) 99 | } 100 | } 101 | } 102 | 103 | /// Retry a task with validation and comprehensive error handling 104 | #[instrument(skip(self), fields(task_id = %task_id), name = "retry_task")] 105 | pub async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError> { 106 | info!("Retrying task: {}", task_id); 107 | 108 | if task_id.is_empty() { 109 | warn!("Empty task ID provided for retry operation"); 110 | return Err(BrokerError::OperationError( 111 | "Task ID cannot be empty".to_string(), 112 | )); 113 | } 114 | 115 | let connection = self.get_pooled_connection("retry_task").await?; 116 | 117 | match TaskOperations::retry_task(&connection, task_id).await { 118 | Ok(()) => { 119 | info!("Successfully retried task: {}", task_id); 120 | Ok(()) 121 | } 122 | Err(e) => { 123 | error!("Failed to retry task {}: {}", task_id, e); 124 | Err(self.add_operation_context(e, "retry_task")) 125 | } 126 | } 127 | } 128 | 129 | /// Revoke a task with validation and comprehensive error handling 130 | #[instrument(skip(self), fields(task_id = %task_id), name = "revoke_task")] 131 | pub async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError> { 132 | info!("Revoking task: {}", task_id); 133 | 134 | if task_id.is_empty() { 135 | warn!("Empty task ID provided for revoke operation"); 136 | return Err(BrokerError::OperationError( 137 | "Task ID cannot be empty".to_string(), 138 | )); 139 | } 140 | 141 | let connection = self.get_pooled_connection("revoke_task").await?; 142 | 143 | match TaskOperations::revoke_task(&connection, task_id).await { 144 | Ok(()) => { 145 | info!("Successfully revoked task: {}", task_id); 146 | Ok(()) 147 | } 148 | Err(e) => { 149 | error!("Failed to revoke task {}: {}", task_id, e); 150 | Err(self.add_operation_context(e, "revoke_task")) 151 | } 152 | } 153 | } 154 | 155 | /// Purge a queue with validation and comprehensive error handling 156 | #[instrument(skip(self), fields(queue_name = %queue_name), name = "purge_queue")] 157 | pub async fn purge_queue(&self, queue_name: &str) -> Result { 158 | info!("Purging queue: {}", queue_name); 159 | 160 | if queue_name.is_empty() { 161 | warn!("Empty queue name provided for purge operation"); 162 | return Err(BrokerError::OperationError( 163 | "Queue name cannot be empty".to_string(), 164 | )); 165 | } 166 | 167 | let connection = self.get_pooled_connection("purge_queue").await?; 168 | 169 | match TaskOperations::purge_queue(&connection, queue_name).await { 170 | Ok(purged_count) => { 171 | info!( 172 | "Successfully purged {} messages from queue: {}", 173 | purged_count, queue_name 174 | ); 175 | Ok(purged_count) 176 | } 177 | Err(e) => { 178 | error!("Failed to purge queue {}: {}", queue_name, e); 179 | Err(self.add_operation_context(e, "purge_queue")) 180 | } 181 | } 182 | } 183 | 184 | /// Perform health check on the connection pool 185 | #[instrument(skip(self), name = "health_check")] 186 | pub async fn health_check(&self) -> Result<(), BrokerError> { 187 | debug!("Performing health check on connection pool"); 188 | 189 | match self.pool.health_check().await { 190 | Ok(()) => { 191 | debug!("Health check passed"); 192 | Ok(()) 193 | } 194 | Err(e) => { 195 | warn!("Health check failed: {}", e); 196 | Err(self.add_operation_context(e, "health_check")) 197 | } 198 | } 199 | } 200 | 201 | /// Get statistics about the connection pool 202 | #[allow(dead_code)] 203 | pub async fn get_pool_stats(&self) -> PoolStats { 204 | // This is a simplified implementation - in a real scenario, 205 | // we'd track more detailed statistics 206 | PoolStats { 207 | active_connections: 1, // Simplified 208 | total_connections: 1, // Simplified 209 | healthy_connections: 1, // Simplified 210 | } 211 | } 212 | 213 | /// Internal method to get a connection from the pool with context 214 | async fn get_pooled_connection( 215 | &self, 216 | operation: &str, 217 | ) -> Result { 218 | debug!("Getting pooled connection for operation: {}", operation); 219 | 220 | self.pool.get_connection().await.map_err(|e| { 221 | error!("Failed to get pooled connection for {}: {}", operation, e); 222 | self.add_operation_context(e, operation) 223 | }) 224 | } 225 | 226 | /// Add contextual information to errors for better debugging 227 | fn add_operation_context(&self, error: BrokerError, operation: &str) -> BrokerError { 228 | match error { 229 | BrokerError::ConnectionError(msg) => { 230 | BrokerError::ConnectionError(format!("Operation '{operation}': {msg}")) 231 | } 232 | BrokerError::OperationError(msg) => { 233 | BrokerError::OperationError(format!("Operation '{operation}': {msg}")) 234 | } 235 | other => other, // Don't modify other error types 236 | } 237 | } 238 | } 239 | 240 | /// Statistics about the connection pool 241 | #[derive(Debug, Clone)] 242 | #[allow(dead_code)] 243 | pub struct PoolStats { 244 | pub active_connections: usize, 245 | pub total_connections: usize, 246 | pub healthy_connections: usize, 247 | } 248 | 249 | impl Drop for BrokerFacade { 250 | fn drop(&mut self) { 251 | debug!("BrokerFacade being dropped"); 252 | // Pool cleanup will happen automatically when Arc is dropped 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/broker/redis/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod facade; 2 | pub mod operations; 3 | pub mod pool; 4 | pub mod protocol; 5 | 6 | use crate::broker::Broker; 7 | use crate::error::BrokerError; 8 | use crate::models::{Queue, Task, Worker}; 9 | use async_trait::async_trait; 10 | use tracing::{debug, info}; 11 | 12 | // Re-export for backward compatibility 13 | pub use facade::BrokerFacade; 14 | 15 | /// Redis broker implementation using the improved facade pattern 16 | pub struct RedisBroker { 17 | facade: BrokerFacade, 18 | } 19 | 20 | #[async_trait] 21 | impl Broker for RedisBroker { 22 | async fn connect(url: &str) -> Result { 23 | info!("Connecting to Redis broker using facade pattern"); 24 | debug!( 25 | "Redis URL: {}", 26 | url.split('@').next_back().unwrap_or("hidden") 27 | ); 28 | 29 | let facade = BrokerFacade::new(url).await?; 30 | 31 | // Perform initial health check 32 | facade.health_check().await?; 33 | 34 | info!("Redis broker connected successfully"); 35 | 36 | Ok(Self { facade }) 37 | } 38 | 39 | async fn get_workers(&self) -> Result, BrokerError> { 40 | self.facade.get_workers().await 41 | } 42 | 43 | async fn get_tasks(&self) -> Result, BrokerError> { 44 | self.facade.get_tasks().await 45 | } 46 | 47 | async fn get_queues(&self) -> Result, BrokerError> { 48 | self.facade.get_queues().await 49 | } 50 | 51 | async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError> { 52 | self.facade.retry_task(task_id).await 53 | } 54 | 55 | async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError> { 56 | self.facade.revoke_task(task_id).await 57 | } 58 | 59 | async fn purge_queue(&self, queue_name: &str) -> Result { 60 | self.facade.purge_queue(queue_name).await 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/broker/redis/operations.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BrokerError; 2 | use redis::aio::MultiplexedConnection; 3 | use redis::AsyncCommands; 4 | use serde_json::Value; 5 | 6 | /// Input validation utilities for Redis operations 7 | mod validation { 8 | use crate::error::BrokerError; 9 | 10 | /// Maximum allowed length for task IDs (based on Celery UUID format) 11 | const MAX_TASK_ID_LENGTH: usize = 36; 12 | 13 | /// Maximum allowed length for queue names 14 | const MAX_QUEUE_NAME_LENGTH: usize = 255; 15 | 16 | /// Valid characters for task IDs (UUID format and alphanumeric) 17 | const TASK_ID_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."; 18 | 19 | /// Valid characters for queue names (alphanumeric, dots, underscores, hyphens) 20 | const QUEUE_NAME_CHARS: &str = 21 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"; 22 | 23 | /// Validate task ID format and content 24 | pub fn validate_task_id(task_id: &str) -> Result<(), BrokerError> { 25 | if task_id.is_empty() { 26 | return Err(BrokerError::ValidationError( 27 | "Task ID cannot be empty".to_string(), 28 | )); 29 | } 30 | 31 | if task_id.len() > MAX_TASK_ID_LENGTH { 32 | return Err(BrokerError::ValidationError(format!( 33 | "Task ID exceeds maximum length of {MAX_TASK_ID_LENGTH} characters" 34 | ))); 35 | } 36 | 37 | // Check for valid UUID-like format (8-4-4-4-12 pattern) 38 | if task_id.len() == 36 { 39 | let parts: Vec<&str> = task_id.split('-').collect(); 40 | if parts.len() != 5 41 | || parts[0].len() != 8 42 | || parts[1].len() != 4 43 | || parts[2].len() != 4 44 | || parts[3].len() != 4 45 | || parts[4].len() != 12 46 | { 47 | return Err(BrokerError::ValidationError( 48 | "Task ID must be in valid UUID format (8-4-4-4-12)".to_string(), 49 | )); 50 | } 51 | } 52 | 53 | // Validate characters 54 | for ch in task_id.chars() { 55 | if !TASK_ID_CHARS.contains(ch) { 56 | return Err(BrokerError::ValidationError(format!( 57 | "Task ID contains invalid character: '{ch}'" 58 | ))); 59 | } 60 | } 61 | 62 | Ok(()) 63 | } 64 | 65 | /// Validate queue name format and content 66 | pub fn validate_queue_name(queue_name: &str) -> Result<(), BrokerError> { 67 | if queue_name.is_empty() { 68 | return Err(BrokerError::ValidationError( 69 | "Queue name cannot be empty".to_string(), 70 | )); 71 | } 72 | 73 | if queue_name.len() > MAX_QUEUE_NAME_LENGTH { 74 | return Err(BrokerError::ValidationError(format!( 75 | "Queue name exceeds maximum length of {MAX_QUEUE_NAME_LENGTH} characters" 76 | ))); 77 | } 78 | 79 | // Validate characters 80 | for ch in queue_name.chars() { 81 | if !QUEUE_NAME_CHARS.contains(ch) { 82 | return Err(BrokerError::ValidationError(format!( 83 | "Queue name contains invalid character: '{ch}'" 84 | ))); 85 | } 86 | } 87 | 88 | // Additional security checks 89 | if queue_name.starts_with('.') || queue_name.ends_with('.') { 90 | return Err(BrokerError::ValidationError( 91 | "Queue name cannot start or end with a dot".to_string(), 92 | )); 93 | } 94 | 95 | if queue_name.contains("..") { 96 | return Err(BrokerError::ValidationError( 97 | "Queue name cannot contain consecutive dots".to_string(), 98 | )); 99 | } 100 | 101 | Ok(()) 102 | } 103 | 104 | /// Sanitize Redis key to prevent injection attacks 105 | pub fn sanitize_redis_key(key: &str) -> Result { 106 | if key.is_empty() { 107 | return Err(BrokerError::ValidationError( 108 | "Redis key cannot be empty".to_string(), 109 | )); 110 | } 111 | 112 | if key.len() > 512 { 113 | return Err(BrokerError::ValidationError( 114 | "Redis key exceeds maximum length of 512 characters".to_string(), 115 | )); 116 | } 117 | 118 | // Check for dangerous patterns 119 | let dangerous_patterns = [ 120 | "EVAL", 121 | "SCRIPT", 122 | "FLUSHALL", 123 | "FLUSHDB", 124 | "CONFIG", 125 | "SHUTDOWN", 126 | "DEBUG", 127 | "SAVE", 128 | "BGSAVE", 129 | "BGREWRITEAOF", 130 | "LASTSAVE", 131 | ]; 132 | 133 | let key_upper = key.to_uppercase(); 134 | for pattern in &dangerous_patterns { 135 | if key_upper.contains(pattern) { 136 | return Err(BrokerError::ValidationError(format!( 137 | "Redis key contains dangerous pattern: {pattern}" 138 | ))); 139 | } 140 | } 141 | 142 | // Only allow safe characters in keys 143 | const SAFE_KEY_CHARS: &str = 144 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-"; 145 | for ch in key.chars() { 146 | if !SAFE_KEY_CHARS.contains(ch) { 147 | return Err(BrokerError::ValidationError(format!( 148 | "Redis key contains unsafe character: '{ch}'" 149 | ))); 150 | } 151 | } 152 | 153 | Ok(key.to_string()) 154 | } 155 | } 156 | 157 | pub struct TaskOperations; 158 | 159 | impl TaskOperations { 160 | pub async fn retry_task( 161 | connection: &MultiplexedConnection, 162 | task_id: &str, 163 | ) -> Result<(), BrokerError> { 164 | // Validate input 165 | validation::validate_task_id(task_id)?; 166 | 167 | let mut conn = connection.clone(); 168 | 169 | // Get the task metadata to extract task information 170 | let task_key = validation::sanitize_redis_key(&format!("celery-task-meta-{task_id}"))?; 171 | let task_data: Option = conn 172 | .get(&task_key) 173 | .await 174 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 175 | 176 | let task_data = task_data 177 | .ok_or_else(|| BrokerError::OperationError(format!("Task {task_id} not found")))?; 178 | 179 | let task_json: Value = serde_json::from_str(&task_data) 180 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 181 | 182 | // Only retry failed tasks 183 | let status = task_json 184 | .get("status") 185 | .and_then(|s| s.as_str()) 186 | .unwrap_or(""); 187 | if status != "FAILURE" { 188 | return Err(BrokerError::OperationError(format!( 189 | "Can only retry failed tasks, task {task_id} is {status}" 190 | ))); 191 | } 192 | 193 | // For a proper retry, we would need the original task message with args/kwargs 194 | // Since we only have the result metadata, we'll update the status to indicate retry 195 | let mut updated_task = task_json.clone(); 196 | updated_task["status"] = Value::String("RETRY".to_string()); 197 | updated_task["retries"] = Value::Number( 198 | (task_json 199 | .get("retries") 200 | .and_then(|r| r.as_i64()) 201 | .unwrap_or(0) 202 | + 1) 203 | .into(), 204 | ); 205 | 206 | // Update the task metadata 207 | let updated_data = serde_json::to_string(&updated_task) 208 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 209 | 210 | conn.set::<_, _, ()>(&task_key, updated_data) 211 | .await 212 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 213 | 214 | // Note: In a real implementation, we would republish the original task message 215 | // to the appropriate queue, but that requires storing the original message 216 | 217 | Ok(()) 218 | } 219 | 220 | pub async fn revoke_task( 221 | connection: &MultiplexedConnection, 222 | task_id: &str, 223 | ) -> Result<(), BrokerError> { 224 | // Validate input 225 | validation::validate_task_id(task_id)?; 226 | 227 | let mut conn = connection.clone(); 228 | 229 | // Add task to Celery's revoked tasks set 230 | let revoked_key = validation::sanitize_redis_key("revoked")?; 231 | conn.sadd::<_, _, ()>(&revoked_key, task_id) 232 | .await 233 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 234 | 235 | // Update task metadata if it exists 236 | let task_key = validation::sanitize_redis_key(&format!("celery-task-meta-{task_id}"))?; 237 | if let Ok(Some(task_data)) = conn.get::<_, Option>(&task_key).await { 238 | if let Ok(mut task_json) = serde_json::from_str::(&task_data) { 239 | // Update status to revoked 240 | task_json["status"] = Value::String("REVOKED".to_string()); 241 | 242 | if let Ok(updated_data) = serde_json::to_string(&task_json) { 243 | let _: Result<(), _> = conn.set(&task_key, updated_data).await; 244 | } 245 | } 246 | } 247 | 248 | // Note: In a real implementation with active workers, the workers would 249 | // check the revoked set and terminate any running tasks with this ID 250 | 251 | Ok(()) 252 | } 253 | 254 | pub async fn purge_queue( 255 | connection: &MultiplexedConnection, 256 | queue_name: &str, 257 | ) -> Result { 258 | // Validate input 259 | validation::validate_queue_name(queue_name)?; 260 | let sanitized_queue = validation::sanitize_redis_key(queue_name)?; 261 | 262 | let mut conn = connection.clone(); 263 | 264 | // Get current queue length for reporting 265 | let queue_length: u64 = conn 266 | .llen(&sanitized_queue) 267 | .await 268 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 269 | 270 | // Delete all messages from the queue (Redis LIST) 271 | // Using DEL command to completely remove the list 272 | let deleted: u64 = conn 273 | .del(&sanitized_queue) 274 | .await 275 | .map_err(|e| BrokerError::OperationError(e.to_string()))?; 276 | 277 | // Return the number of messages that were purged 278 | if deleted > 0 { 279 | Ok(queue_length) 280 | } else { 281 | Ok(0) 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/broker/redis/pool.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BrokerError; 2 | use redis::aio::MultiplexedConnection; 3 | use redis::Client; 4 | use std::sync::Arc; 5 | use std::time::{Duration, Instant}; 6 | use tokio::sync::{Mutex, Semaphore}; 7 | use tokio::time::sleep; 8 | 9 | const DEFAULT_POOL_SIZE: usize = 10; 10 | #[allow(dead_code)] 11 | const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); 12 | const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30); 13 | const MAX_RETRY_ATTEMPTS: u32 = 3; 14 | const INITIAL_BACKOFF: Duration = Duration::from_millis(100); 15 | 16 | #[derive(Debug)] 17 | pub struct PooledConnection { 18 | pub connection: MultiplexedConnection, 19 | pub last_used: Instant, 20 | pub is_healthy: bool, 21 | } 22 | 23 | impl PooledConnection { 24 | fn new(connection: MultiplexedConnection) -> Self { 25 | Self { 26 | connection, 27 | last_used: Instant::now(), 28 | is_healthy: true, 29 | } 30 | } 31 | 32 | pub fn mark_used(&mut self) { 33 | self.last_used = Instant::now(); 34 | } 35 | 36 | pub async fn health_check(&mut self) -> bool { 37 | // Simple ping to check if connection is alive 38 | let mut conn = self.connection.clone(); 39 | match tokio::time::timeout( 40 | Duration::from_secs(1), 41 | redis::cmd("PING").query_async::<_, String>(&mut conn), 42 | ) 43 | .await 44 | { 45 | Ok(Ok(_)) => { 46 | self.is_healthy = true; 47 | true 48 | } 49 | _ => { 50 | self.is_healthy = false; 51 | false 52 | } 53 | } 54 | } 55 | } 56 | 57 | pub struct ConnectionPool { 58 | client: Client, 59 | connections: Arc>>, 60 | semaphore: Arc, 61 | max_size: usize, 62 | } 63 | 64 | impl ConnectionPool { 65 | pub async fn new(url: &str, max_size: Option) -> Result { 66 | let client = Client::open(url) 67 | .map_err(|e| BrokerError::InvalidUrl(format!("Invalid Redis URL: {e}")))?; 68 | 69 | let max_size = max_size.unwrap_or(DEFAULT_POOL_SIZE); 70 | let connections = Arc::new(Mutex::new(Vec::with_capacity(max_size))); 71 | let semaphore = Arc::new(Semaphore::new(max_size)); 72 | 73 | let pool = Self { 74 | client, 75 | connections, 76 | semaphore, 77 | max_size, 78 | }; 79 | 80 | // Pre-populate pool with one connection to test connectivity 81 | pool.create_connection().await?; 82 | 83 | Ok(pool) 84 | } 85 | 86 | async fn create_connection(&self) -> Result { 87 | let connection = self 88 | .client 89 | .get_multiplexed_tokio_connection() 90 | .await 91 | .map_err(|e| { 92 | BrokerError::ConnectionError(format!("Failed to create connection: {e}")) 93 | })?; 94 | 95 | Ok(PooledConnection::new(connection)) 96 | } 97 | 98 | pub async fn get_connection(&self) -> Result { 99 | // Acquire semaphore permit first to limit concurrent connections 100 | let _permit = self 101 | .semaphore 102 | .clone() 103 | .acquire_owned() 104 | .await 105 | .map_err(|_| BrokerError::ConnectionError("Pool semaphore error".to_string()))?; 106 | 107 | // Try to get an existing healthy connection 108 | let mut connections = self.connections.lock().await; 109 | 110 | // Look for a healthy connection 111 | if let Some(index) = connections.iter().position(|conn| conn.is_healthy) { 112 | let mut pooled_conn = connections.remove(index); 113 | pooled_conn.mark_used(); 114 | 115 | // Quick health check for connections that haven't been used recently 116 | if pooled_conn.last_used.elapsed() > HEALTH_CHECK_INTERVAL 117 | && !pooled_conn.health_check().await 118 | { 119 | // Connection is unhealthy, create a new one 120 | drop(connections); // Release lock before creating new connection 121 | return self 122 | .create_connection_with_retry() 123 | .await 124 | .map(|conn| conn.connection); 125 | } 126 | 127 | let connection = pooled_conn.connection.clone(); 128 | connections.push(pooled_conn); // Return to pool 129 | return Ok(connection); 130 | } 131 | 132 | // No healthy connections available, create new one if under max size 133 | if connections.len() < self.max_size { 134 | drop(connections); // Release lock before creating new connection 135 | return self 136 | .create_connection_with_retry() 137 | .await 138 | .map(|conn| conn.connection); 139 | } 140 | 141 | // Pool is full, return the oldest connection 142 | if let Some(mut pooled_conn) = connections.pop() { 143 | pooled_conn.mark_used(); 144 | let connection = pooled_conn.connection.clone(); 145 | connections.push(pooled_conn); 146 | Ok(connection) 147 | } else { 148 | // Should not happen, but fallback to creating new connection 149 | drop(connections); 150 | self.create_connection_with_retry() 151 | .await 152 | .map(|conn| conn.connection) 153 | } 154 | } 155 | 156 | async fn create_connection_with_retry(&self) -> Result { 157 | let mut attempt = 0; 158 | let mut backoff = INITIAL_BACKOFF; 159 | 160 | while attempt < MAX_RETRY_ATTEMPTS { 161 | match self.create_connection().await { 162 | Ok(conn) => return Ok(conn), 163 | Err(e) if attempt == MAX_RETRY_ATTEMPTS - 1 => return Err(e), 164 | Err(_) => { 165 | attempt += 1; 166 | sleep(backoff).await; 167 | backoff = std::cmp::min(backoff * 2, Duration::from_secs(5)); 168 | } 169 | } 170 | } 171 | 172 | Err(BrokerError::ConnectionError( 173 | "Failed to create connection after retries".to_string(), 174 | )) 175 | } 176 | 177 | #[allow(dead_code)] 178 | pub async fn return_connection(&self, connection: MultiplexedConnection) { 179 | let mut connections = self.connections.lock().await; 180 | if connections.len() < self.max_size { 181 | connections.push(PooledConnection::new(connection)); 182 | } 183 | // If pool is full, just drop the connection 184 | } 185 | 186 | pub async fn health_check(&self) -> Result<(), BrokerError> { 187 | let mut connections = self.connections.lock().await; 188 | 189 | // Check health of all pooled connections 190 | for conn in connections.iter_mut() { 191 | if !conn.health_check().await { 192 | // Mark as unhealthy - it will be replaced on next use 193 | conn.is_healthy = false; 194 | } 195 | } 196 | 197 | // Remove unhealthy connections 198 | connections.retain(|conn| conn.is_healthy); 199 | 200 | Ok(()) 201 | } 202 | 203 | #[allow(dead_code)] 204 | pub async fn close(&self) { 205 | let mut connections = self.connections.lock().await; 206 | connections.clear(); 207 | } 208 | } 209 | 210 | impl Drop for ConnectionPool { 211 | fn drop(&mut self) { 212 | // Note: Cannot run async code in Drop, so connections will be cleaned up 213 | // when they go out of scope. For proper cleanup, call close() explicitly. 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/broker/redis/protocol/mod.rs: -------------------------------------------------------------------------------- 1 | //! Redis protocol parsing modules 2 | //! 3 | //! This module contains parsers for different Celery protocol data types. 4 | //! Each parser is responsible for parsing a specific type of data from Redis. 5 | 6 | mod queue_parser; 7 | mod task_parser; 8 | mod worker_parser; 9 | 10 | pub use queue_parser::QueueParser; 11 | pub use task_parser::TaskParser; 12 | pub use worker_parser::WorkerParser; 13 | 14 | // Re-export the main ProtocolParser for backward compatibility 15 | use crate::error::BrokerError; 16 | use crate::models::{Queue, Task, Worker}; 17 | use redis::aio::MultiplexedConnection; 18 | 19 | /// Main protocol parser that delegates to specialized parsers 20 | pub struct ProtocolParser; 21 | 22 | impl ProtocolParser { 23 | /// Parse workers from Redis connection 24 | pub async fn parse_workers( 25 | connection: &MultiplexedConnection, 26 | ) -> Result, BrokerError> { 27 | WorkerParser::parse_workers(connection).await 28 | } 29 | 30 | /// Parse tasks from Redis connection 31 | pub async fn parse_tasks(connection: &MultiplexedConnection) -> Result, BrokerError> { 32 | TaskParser::parse_tasks(connection).await 33 | } 34 | 35 | /// Parse queues from Redis connection 36 | pub async fn parse_queues( 37 | connection: &MultiplexedConnection, 38 | ) -> Result, BrokerError> { 39 | QueueParser::parse_queues(connection).await 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/broker/redis/protocol/queue_parser.rs: -------------------------------------------------------------------------------- 1 | //! Queue parser for Redis Celery protocol 2 | //! 3 | //! This module handles parsing queue information from Redis data structures. 4 | //! It discovers queues from kombu bindings and checks standard queue names 5 | //! to provide information about queue status and message counts. 6 | 7 | use crate::error::BrokerError; 8 | use crate::models::Queue; 9 | use redis::aio::MultiplexedConnection; 10 | use redis::AsyncCommands; 11 | use std::collections::HashSet; 12 | 13 | /// Parser for queue-related data from Redis 14 | pub struct QueueParser; 15 | 16 | impl QueueParser { 17 | /// Parse queues from Redis connection 18 | /// 19 | /// Discovers active queues from kombu bindings and standard queue names, 20 | /// then checks their length and consumer information to build a comprehensive 21 | /// view of the queue system. 22 | pub async fn parse_queues( 23 | connection: &MultiplexedConnection, 24 | ) -> Result, BrokerError> { 25 | let mut conn = connection.clone(); 26 | let mut queues = Vec::new(); 27 | let mut discovered_queues = HashSet::new(); 28 | 29 | // First, discover queues from kombu bindings 30 | let binding_keys: Vec = conn.keys("_kombu.binding.*").await.unwrap_or_default(); 31 | 32 | for binding_key in binding_keys { 33 | if let Some(queue_name) = binding_key.strip_prefix("_kombu.binding.") { 34 | discovered_queues.insert(queue_name.to_string()); 35 | } 36 | } 37 | 38 | // Also check for common queue names 39 | let common_queues = vec!["celery", "default", "priority", "high", "low"]; 40 | for queue_name in common_queues { 41 | discovered_queues.insert(queue_name.to_string()); 42 | } 43 | 44 | // Check each discovered queue 45 | for queue_name in discovered_queues { 46 | let length: u64 = conn.llen(&queue_name).await.unwrap_or(0); 47 | 48 | // Only include queues that exist (have been used) or are standard 49 | if length > 0 || ["celery", "default"].contains(&queue_name.as_str()) { 50 | // Estimate consumers from worker data (simplified) 51 | let consumers = if length > 0 { 1 } else { 0 }; // Simplified consumer count 52 | 53 | queues.push(Queue { 54 | name: queue_name, 55 | length, 56 | consumers, 57 | }); 58 | } 59 | } 60 | 61 | // Sort queues by name for consistent display 62 | queues.sort_by(|a, b| a.name.cmp(&b.name)); 63 | 64 | Ok(queues) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/broker/redis/protocol/worker_parser.rs: -------------------------------------------------------------------------------- 1 | //! Worker parser for Redis Celery protocol 2 | //! 3 | //! This module handles parsing worker information from Redis data structures. 4 | //! It extracts worker statistics, status, and queue assignments from task metadata 5 | //! and queue messages. 6 | 7 | use crate::error::BrokerError; 8 | use crate::models::{Worker, WorkerStatus}; 9 | use redis::aio::MultiplexedConnection; 10 | use redis::AsyncCommands; 11 | use serde_json::Value; 12 | use std::collections::HashMap; 13 | 14 | // Configuration constants for worker parsing 15 | const MAX_TASK_METADATA_KEYS: usize = 500; 16 | const DEFAULT_WORKER_CONCURRENCY: u32 = 16; 17 | 18 | /// Parser for worker-related data from Redis 19 | pub struct WorkerParser; 20 | 21 | impl WorkerParser { 22 | /// Parse workers from Redis connection 23 | /// 24 | /// Extracts worker information from task metadata and queue messages to build 25 | /// a comprehensive view of active workers, their status, and statistics. 26 | pub async fn parse_workers( 27 | connection: &MultiplexedConnection, 28 | ) -> Result, BrokerError> { 29 | let mut conn = connection.clone(); 30 | let mut worker_stats: HashMap)> = HashMap::new(); 31 | let active_workers: HashMap> = HashMap::new(); 32 | 33 | // Get task metadata and extract worker information 34 | Self::get_task_metadata(&mut conn, &mut worker_stats).await?; 35 | 36 | // Extract worker info from queue messages 37 | Self::extract_worker_info_from_queues(&mut conn, &mut worker_stats).await?; 38 | 39 | // Build the final worker list 40 | let mut workers = Self::build_worker_list(worker_stats, active_workers); 41 | 42 | // Handle case where no workers are detected 43 | Self::ensure_default_worker_if_needed(&mut conn, &mut workers).await?; 44 | 45 | Ok(workers) 46 | } 47 | 48 | /// Extract worker statistics from task metadata 49 | /// 50 | /// Processes completed task metadata to extract worker performance statistics 51 | /// including processed and failed task counts. 52 | async fn get_task_metadata( 53 | conn: &mut MultiplexedConnection, 54 | worker_stats: &mut HashMap)>, 55 | ) -> Result<(), BrokerError> { 56 | let task_keys: Vec = conn.keys("celery-task-meta-*").await.map_err(|e| { 57 | BrokerError::OperationError(format!("Failed to get task metadata keys: {e}")) 58 | })?; 59 | 60 | for key in task_keys.iter().take(MAX_TASK_METADATA_KEYS) { 61 | match conn.get::<_, String>(key).await { 62 | Ok(data) => { 63 | match serde_json::from_str::(&data) { 64 | Ok(task_data) => { 65 | let status = task_data 66 | .get("status") 67 | .and_then(|s| s.as_str()) 68 | .unwrap_or("UNKNOWN"); 69 | 70 | // For completed tasks, we don't have hostname in metadata 71 | // So we'll create a generic worker based on activity 72 | let hostname = "celery-worker".to_string(); 73 | let (processed, failed, queues) = 74 | worker_stats.entry(hostname).or_insert((0, 0, Vec::new())); 75 | 76 | match status { 77 | "SUCCESS" => *processed += 1, 78 | "FAILURE" => *failed += 1, 79 | _ => {} 80 | } 81 | 82 | // Add default queue 83 | if !queues.contains(&"celery".to_string()) { 84 | queues.push("celery".to_string()); 85 | } 86 | } 87 | Err(_) => { 88 | // Skip malformed task data - log error but continue processing 89 | continue; 90 | } 91 | } 92 | } 93 | Err(_) => { 94 | // Skip inaccessible keys - continue processing other tasks 95 | continue; 96 | } 97 | } 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | /// Extract worker information from queue messages 104 | /// 105 | /// Analyzes pending tasks in queues to identify worker hostnames and 106 | /// associated queue assignments. 107 | async fn extract_worker_info_from_queues( 108 | conn: &mut MultiplexedConnection, 109 | worker_stats: &mut HashMap)>, 110 | ) -> Result<(), BrokerError> { 111 | let queue_names = vec!["celery", "default", "priority"]; 112 | 113 | for queue_name in queue_names { 114 | match conn.llen::<_, u64>(queue_name).await { 115 | Ok(queue_length) if queue_length > 0 => { 116 | match conn.lrange::<_, Vec>(queue_name, 0, 5).await { 117 | Ok(messages) => { 118 | for message in &messages { 119 | if let Ok(task_message) = serde_json::from_str::(message) { 120 | if let Some(hostname) = 121 | Self::extract_hostname_from_message(&task_message) 122 | { 123 | let (_processed, _failed, queues) = worker_stats 124 | .entry(hostname) 125 | .or_insert((0, 0, Vec::new())); 126 | if !queues.contains(&queue_name.to_string()) { 127 | queues.push(queue_name.to_string()); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | Err(_) => { 134 | // Skip queue if we can't read messages - continue with other queues 135 | continue; 136 | } 137 | } 138 | } 139 | _ => { 140 | // Skip empty or inaccessible queues 141 | continue; 142 | } 143 | } 144 | } 145 | 146 | Ok(()) 147 | } 148 | 149 | /// Extract hostname from a task message 150 | /// 151 | /// Parses the 'origin' field from task headers to extract the worker hostname. 152 | /// Handles various origin formats like "gen447152@archflowx13". 153 | fn extract_hostname_from_message(task_message: &Value) -> Option { 154 | task_message 155 | .get("headers") 156 | .and_then(|headers| headers.get("origin")) 157 | .and_then(|origin| origin.as_str()) 158 | .map(|origin| { 159 | // Extract hostname from origin like "gen447152@archflowx13" 160 | if let Some(at_pos) = origin.find('@') { 161 | origin[at_pos + 1..].to_string() 162 | } else { 163 | origin.to_string() 164 | } 165 | }) 166 | } 167 | 168 | /// Build the final worker list from collected statistics 169 | /// 170 | /// Converts raw worker statistics into Worker structs with appropriate 171 | /// status determination and queue assignments. 172 | fn build_worker_list( 173 | worker_stats: HashMap)>, 174 | active_workers: HashMap>, 175 | ) -> Vec { 176 | let mut workers = Vec::new(); 177 | 178 | for (hostname, (processed, failed, queues)) in worker_stats { 179 | let active_tasks = active_workers.get(&hostname).cloned().unwrap_or_default(); 180 | 181 | // Determine worker status - if we have recent task data, assume online 182 | let status = if processed > 0 || failed > 0 { 183 | WorkerStatus::Online 184 | } else { 185 | WorkerStatus::Offline 186 | }; 187 | 188 | workers.push(Worker { 189 | hostname, 190 | status, 191 | concurrency: DEFAULT_WORKER_CONCURRENCY, 192 | queues: if queues.is_empty() { 193 | vec!["celery".to_string()] 194 | } else { 195 | queues 196 | }, 197 | active_tasks, 198 | processed, 199 | failed, 200 | }); 201 | } 202 | 203 | workers 204 | } 205 | 206 | /// Ensure at least one worker exists if activity is detected 207 | /// 208 | /// Creates a default worker when no specific workers are found but 209 | /// there is evidence of Celery activity (pending tasks or completed tasks). 210 | async fn ensure_default_worker_if_needed( 211 | conn: &mut MultiplexedConnection, 212 | workers: &mut Vec, 213 | ) -> Result<(), BrokerError> { 214 | if workers.is_empty() { 215 | let celery_queue_len: u64 = conn.llen("celery").await.unwrap_or(0); 216 | let task_keys: Vec = conn.keys("celery-task-meta-*").await.map_err(|e| { 217 | BrokerError::OperationError(format!("Failed to check for task metadata keys: {e}")) 218 | })?; 219 | let task_count = task_keys.len(); 220 | 221 | if celery_queue_len > 0 || task_count > 0 { 222 | // There is activity, so assume a worker exists 223 | workers.push(Worker { 224 | hostname: "detected-worker".to_string(), 225 | status: if celery_queue_len > 0 { 226 | WorkerStatus::Offline 227 | } else { 228 | WorkerStatus::Online 229 | }, 230 | concurrency: DEFAULT_WORKER_CONCURRENCY, 231 | queues: vec!["celery".to_string()], 232 | active_tasks: vec![], 233 | processed: task_count as u64, 234 | failed: 0, 235 | }); 236 | } 237 | } 238 | 239 | Ok(()) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::{Deserialize, Serialize}; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct Config { 7 | pub broker: BrokerConfig, 8 | pub ui: UiConfig, 9 | } 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize)] 12 | pub struct BrokerConfig { 13 | pub url: String, 14 | pub timeout: u32, 15 | pub retry_attempts: u32, 16 | } 17 | 18 | #[derive(Debug, Clone, Serialize, Deserialize)] 19 | pub struct UiConfig { 20 | pub refresh_interval: u64, // milliseconds 21 | pub theme: String, 22 | } 23 | 24 | impl Default for Config { 25 | fn default() -> Self { 26 | Self { 27 | broker: BrokerConfig { 28 | url: "redis://localhost:6379/0".to_string(), 29 | timeout: 30, 30 | retry_attempts: 3, 31 | }, 32 | ui: UiConfig { 33 | refresh_interval: 1000, 34 | theme: "dark".to_string(), 35 | }, 36 | } 37 | } 38 | } 39 | 40 | impl Config { 41 | pub fn from_file(path: PathBuf) -> Result { 42 | let contents = std::fs::read_to_string(path)?; 43 | let config: Config = toml::from_str(&contents)?; 44 | Ok(config) 45 | } 46 | 47 | pub fn load_or_create_default() -> Result { 48 | let config_dir = dirs::config_dir() 49 | .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))? 50 | .join("lazycelery"); 51 | 52 | let config_path = config_dir.join("config.toml"); 53 | 54 | if config_path.exists() { 55 | Self::from_file(config_path) 56 | } else { 57 | // Create default config 58 | let default_config = Self::default(); 59 | 60 | // Try to create config directory and file 61 | if let Err(e) = std::fs::create_dir_all(&config_dir) { 62 | eprintln!("⚠️ Could not create config directory: {e}"); 63 | } else { 64 | let toml_string = toml::to_string_pretty(&default_config)?; 65 | if let Err(e) = std::fs::write(&config_path, toml_string) { 66 | eprintln!("⚠️ Could not create config file: {e}"); 67 | } else { 68 | eprintln!("✅ Created default config at: {}", config_path.display()); 69 | } 70 | } 71 | 72 | Ok(default_config) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | #[allow(dead_code)] 5 | pub enum BrokerError { 6 | #[error("Connection failed: {0}")] 7 | ConnectionError(String), 8 | 9 | #[error("Authentication failed")] 10 | AuthError, 11 | 12 | #[error("Broker operation failed: {0}")] 13 | OperationError(String), 14 | 15 | #[error("Invalid broker URL: {0}")] 16 | InvalidUrl(String), 17 | 18 | #[error("Validation error: {0}")] 19 | ValidationError(String), 20 | 21 | #[error("Timeout occurred")] 22 | Timeout, 23 | 24 | #[error("Not implemented")] 25 | NotImplemented, 26 | } 27 | 28 | #[derive(Debug, Error)] 29 | #[allow(dead_code)] 30 | pub enum AppError { 31 | #[error("Broker error: {0}")] 32 | Broker(#[from] BrokerError), 33 | 34 | #[error("UI error: {0}")] 35 | Ui(String), 36 | 37 | #[error("Configuration error: {0}")] 38 | Config(String), 39 | } 40 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod broker; 3 | pub mod config; 4 | pub mod error; 5 | pub mod models; 6 | pub mod ui; 7 | pub mod utils; 8 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod queue; 2 | pub mod task; 3 | pub mod worker; 4 | 5 | pub use queue::Queue; 6 | pub use task::{Task, TaskStatus}; 7 | pub use worker::{Worker, WorkerStatus}; 8 | -------------------------------------------------------------------------------- /src/models/queue.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct Queue { 5 | pub name: String, 6 | pub length: u64, 7 | pub consumers: u32, 8 | } 9 | 10 | impl Queue { 11 | /// Basic constructor for creating a new Queue - kept for future API use 12 | #[allow(dead_code)] 13 | pub fn new(name: String) -> Self { 14 | Self { 15 | name, 16 | length: 0, 17 | consumers: 0, 18 | } 19 | } 20 | 21 | pub fn is_empty(&self) -> bool { 22 | self.length == 0 23 | } 24 | 25 | pub fn has_consumers(&self) -> bool { 26 | self.consumers > 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/models/task.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct Task { 6 | pub id: String, 7 | pub name: String, 8 | pub args: String, // JSON string 9 | pub kwargs: String, // JSON string 10 | pub status: TaskStatus, 11 | pub worker: Option, 12 | pub timestamp: DateTime, 13 | pub result: Option, 14 | pub traceback: Option, 15 | } 16 | 17 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 18 | pub enum TaskStatus { 19 | Pending, 20 | Active, 21 | Success, 22 | Failure, 23 | Retry, 24 | Revoked, 25 | } 26 | 27 | impl Task { 28 | /// Basic constructor for creating a new Task - kept for future API use 29 | #[allow(dead_code)] 30 | pub fn new(id: String, name: String) -> Self { 31 | Self { 32 | id, 33 | name, 34 | args: "[]".to_string(), 35 | kwargs: "{}".to_string(), 36 | status: TaskStatus::Pending, 37 | worker: None, 38 | timestamp: Utc::now(), 39 | result: None, 40 | traceback: None, 41 | } 42 | } 43 | 44 | pub fn duration_since(&self, now: DateTime) -> chrono::Duration { 45 | now - self.timestamp 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/models/worker.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct Worker { 5 | pub hostname: String, 6 | pub status: WorkerStatus, 7 | pub concurrency: u32, 8 | pub queues: Vec, 9 | pub active_tasks: Vec, 10 | pub processed: u64, 11 | pub failed: u64, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 15 | pub enum WorkerStatus { 16 | Online, 17 | Offline, 18 | } 19 | 20 | impl Worker { 21 | /// Basic constructor for creating a new Worker - kept for future API use 22 | #[allow(dead_code)] 23 | pub fn new(hostname: String) -> Self { 24 | Self { 25 | hostname, 26 | status: WorkerStatus::Offline, 27 | concurrency: 1, 28 | queues: Vec::new(), 29 | active_tasks: Vec::new(), 30 | processed: 0, 31 | failed: 0, 32 | } 33 | } 34 | 35 | pub fn utilization(&self) -> f32 { 36 | if self.concurrency == 0 { 37 | 0.0 38 | } else { 39 | (self.active_tasks.len() as f32 / self.concurrency as f32) * 100.0 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/events.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{self, Event, KeyCode, KeyEvent}; 2 | use std::time::Duration; 3 | 4 | #[allow(dead_code)] 5 | pub enum AppEvent { 6 | Key(KeyEvent), 7 | Tick, 8 | Refresh, 9 | } 10 | 11 | pub async fn next_event(tick_rate: Duration) -> Result { 12 | if event::poll(tick_rate)? { 13 | match event::read()? { 14 | Event::Key(key) => Ok(AppEvent::Key(key)), 15 | _ => Ok(AppEvent::Tick), 16 | } 17 | } else { 18 | Ok(AppEvent::Tick) 19 | } 20 | } 21 | 22 | pub fn handle_key_event(key: KeyEvent, app: &mut crate::app::App) { 23 | if app.is_searching { 24 | match key.code { 25 | KeyCode::Esc => app.stop_search(), 26 | KeyCode::Enter => app.stop_search(), 27 | KeyCode::Char(c) => app.search_query.push(c), 28 | KeyCode::Backspace => { 29 | app.search_query.pop(); 30 | } 31 | _ => {} 32 | } 33 | return; 34 | } 35 | 36 | if app.show_confirmation { 37 | match key.code { 38 | KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { 39 | // Confirmation dialog will be handled in main loop 40 | } 41 | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { 42 | app.hide_confirmation_dialog(); 43 | } 44 | _ => {} 45 | } 46 | return; 47 | } 48 | 49 | if app.show_help { 50 | app.toggle_help(); 51 | return; 52 | } 53 | 54 | if app.show_task_details { 55 | app.hide_task_details(); 56 | return; 57 | } 58 | 59 | // Clear status message on any key press (except actions that set new status) 60 | match key.code { 61 | KeyCode::Char('p') 62 | | KeyCode::Char('r') 63 | | KeyCode::Char('x') 64 | | KeyCode::Enter 65 | | KeyCode::Char('d') => { 66 | // These will set their own status messages or open modals 67 | } 68 | _ => { 69 | app.clear_status_message(); 70 | } 71 | } 72 | 73 | match key.code { 74 | KeyCode::Char('q') => app.should_quit = true, 75 | KeyCode::Char('?') => app.toggle_help(), 76 | KeyCode::Tab => app.next_tab(), 77 | KeyCode::BackTab => app.previous_tab(), 78 | KeyCode::Up | KeyCode::Char('k') => app.select_previous(), 79 | KeyCode::Down | KeyCode::Char('j') => app.select_next(), 80 | KeyCode::Char('/') => app.start_search(), 81 | KeyCode::Char('p') => app.initiate_purge_queue(), 82 | KeyCode::Char('r') => app.initiate_retry_task(), 83 | KeyCode::Char('x') => app.initiate_revoke_task(), 84 | KeyCode::Enter | KeyCode::Char('d') => app.show_task_details(), 85 | _ => {} 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ui/layout.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::Span, 5 | widgets::{Block, Borders, Tabs}, 6 | Frame, 7 | }; 8 | 9 | use crate::app::{App, Tab}; 10 | 11 | /// Draw the header section with tab navigation 12 | pub fn draw_header(f: &mut Frame, app: &App, area: Rect) { 13 | let titles = vec!["Workers", "Queues", "Tasks"]; 14 | let selected = match app.selected_tab { 15 | Tab::Workers => 0, 16 | Tab::Queues => 1, 17 | Tab::Tasks => 2, 18 | }; 19 | 20 | let tabs = Tabs::new(titles) 21 | .block( 22 | Block::default() 23 | .borders(Borders::ALL) 24 | .title(" LazyCelery v0.4.0 "), 25 | ) 26 | .select(selected) 27 | .style(Style::default().fg(Color::Cyan)) 28 | .highlight_style( 29 | Style::default() 30 | .add_modifier(Modifier::BOLD) 31 | .bg(Color::Black), 32 | ); 33 | 34 | f.render_widget(tabs, area); 35 | } 36 | 37 | /// Draw the status bar with information and key hints 38 | pub fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { 39 | let status_chunks = Layout::default() 40 | .direction(Direction::Horizontal) 41 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 42 | .split(area); 43 | 44 | // Left side - general info or status message 45 | let status_left = if !app.status_message.is_empty() { 46 | app.status_message.clone() 47 | } else if app.is_searching { 48 | format!("Search: {}_", app.search_query) 49 | } else { 50 | format!( 51 | "Workers: {} | Tasks: {} | Queues: {}", 52 | app.workers.len(), 53 | app.tasks.len(), 54 | app.queues.len() 55 | ) 56 | }; 57 | 58 | let status_left_widget = Block::default() 59 | .borders(Borders::ALL) 60 | .title(Span::raw(status_left)); 61 | f.render_widget(status_left_widget, status_chunks[0]); 62 | 63 | // Right side - key hints 64 | let key_hints = get_key_hints(app); 65 | 66 | let status_right_widget = Block::default() 67 | .borders(Borders::ALL) 68 | .title(Span::raw(key_hints)); 69 | f.render_widget(status_right_widget, status_chunks[1]); 70 | } 71 | 72 | /// Get appropriate key hints based on current application state 73 | fn get_key_hints(app: &App) -> &'static str { 74 | if app.show_confirmation { 75 | "[y/Enter] Confirm | [n/Esc] Cancel" 76 | } else if app.show_task_details { 77 | "[Any key] Close details" 78 | } else if app.is_searching { 79 | "[Enter] Confirm | [Esc] Cancel" 80 | } else { 81 | match app.selected_tab { 82 | Tab::Queues => "[Tab] Switch | [↑↓] Navigate | [p] Purge | [/] Search | [?] Help | [q] Quit", 83 | Tab::Tasks => "[Tab] Switch | [↑↓] Navigate | [Enter/d] Details | [r] Retry | [x] Revoke | [/] Search | [?] Help | [q] Quit", 84 | _ => "[Tab] Switch | [↑↓] Navigate | [/] Search | [?] Help | [q] Quit", 85 | } 86 | } 87 | } 88 | 89 | /// Create the main application layout with header, content, and status bar 90 | pub fn create_main_layout(area: Rect) -> Vec { 91 | Layout::default() 92 | .direction(Direction::Vertical) 93 | .constraints([ 94 | Constraint::Length(3), // Header 95 | Constraint::Min(0), // Main content 96 | Constraint::Length(3), // Status bar 97 | ]) 98 | .split(area) 99 | .to_vec() 100 | } 101 | 102 | /// Create a centered rectangle for modal dialogs 103 | pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 104 | let popup_layout = Layout::default() 105 | .direction(Direction::Vertical) 106 | .constraints([ 107 | Constraint::Percentage((100 - percent_y) / 2), 108 | Constraint::Percentage(percent_y), 109 | Constraint::Percentage((100 - percent_y) / 2), 110 | ]) 111 | .split(r); 112 | 113 | Layout::default() 114 | .direction(Direction::Horizontal) 115 | .constraints([ 116 | Constraint::Percentage((100 - percent_x) / 2), 117 | Constraint::Percentage(percent_x), 118 | Constraint::Percentage((100 - percent_x) / 2), 119 | ]) 120 | .split(popup_layout[1])[1] 121 | } 122 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | pub mod layout; 3 | pub mod modals; 4 | pub mod widgets; 5 | 6 | use ratatui::Frame; 7 | 8 | use crate::app::{App, Tab}; 9 | use crate::ui::layout::{create_main_layout, draw_header, draw_status_bar}; 10 | use crate::ui::modals::{draw_confirmation_dialog, draw_help, draw_task_details_modal}; 11 | use crate::ui::widgets::{QueueWidget, TaskWidget, Widget, WorkerWidget}; 12 | 13 | pub fn draw(f: &mut Frame, app: &mut App) { 14 | let chunks = create_main_layout(f.area()); 15 | 16 | // Draw header with tabs 17 | draw_header(f, app, chunks[0]); 18 | 19 | // Draw main content based on selected tab 20 | match app.selected_tab { 21 | Tab::Workers => WorkerWidget::draw(f, app, chunks[1]), 22 | Tab::Tasks => TaskWidget::draw(f, app, chunks[1]), 23 | Tab::Queues => QueueWidget::draw(f, app, chunks[1]), 24 | } 25 | 26 | // Draw status bar 27 | draw_status_bar(f, app, chunks[2]); 28 | 29 | // Draw help overlay if active 30 | if app.show_help { 31 | draw_help(f); 32 | } 33 | 34 | // Draw confirmation dialog if active 35 | if app.show_confirmation { 36 | draw_confirmation_dialog(f, app); 37 | } 38 | 39 | // Draw task details modal if active 40 | if app.show_task_details { 41 | draw_task_details_modal(f, app); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/modals.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Layout}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, Clear, Paragraph, Wrap}, 6 | Frame, 7 | }; 8 | 9 | use super::layout::centered_rect; 10 | use crate::app::App; 11 | 12 | /// Draw the help modal overlay 13 | pub fn draw_help(f: &mut Frame) { 14 | let area = centered_rect(60, 60, f.area()); 15 | f.render_widget(Clear, area); 16 | 17 | let help_text = vec![ 18 | Line::from("LazyCelery - Keyboard Shortcuts"), 19 | Line::from(""), 20 | Line::from("Navigation:"), 21 | Line::from(" Tab - Switch between tabs"), 22 | Line::from(" ↑/k - Move up"), 23 | Line::from(" ↓/j - Move down"), 24 | Line::from(" Enter/d - View details (in Tasks tab)"), 25 | Line::from(" Esc - Go back"), 26 | Line::from(""), 27 | Line::from("Actions:"), 28 | Line::from(" / - Search"), 29 | Line::from(" p - Purge queue (in Queues tab)"), 30 | Line::from(" r - Retry task (in Tasks tab)"), 31 | Line::from(" x - Revoke task (in Tasks tab)"), 32 | Line::from(""), 33 | Line::from("General:"), 34 | Line::from(" ? - Toggle this help"), 35 | Line::from(" q - Quit application"), 36 | Line::from(""), 37 | Line::from("Press any key to close this help..."), 38 | ]; 39 | 40 | let help = Paragraph::new(help_text) 41 | .block( 42 | Block::default() 43 | .borders(Borders::ALL) 44 | .title(" Help ") 45 | .style(Style::default().bg(Color::Black)), 46 | ) 47 | .wrap(Wrap { trim: true }); 48 | 49 | f.render_widget(help, area); 50 | } 51 | 52 | /// Draw the confirmation dialog modal 53 | pub fn draw_confirmation_dialog(f: &mut Frame, app: &App) { 54 | let area = centered_rect(50, 30, f.area()); 55 | f.render_widget(Clear, area); 56 | 57 | let confirmation_text = vec![ 58 | Line::from(""), 59 | Line::from(app.confirmation_message.clone()), 60 | Line::from(""), 61 | Line::from("Press [y/Enter] to confirm or [n/Esc] to cancel"), 62 | ]; 63 | 64 | let confirmation = Paragraph::new(confirmation_text) 65 | .block( 66 | Block::default() 67 | .borders(Borders::ALL) 68 | .title(" Confirmation ") 69 | .style(Style::default().bg(Color::Black).fg(Color::Yellow)), 70 | ) 71 | .wrap(Wrap { trim: true }); 72 | 73 | f.render_widget(confirmation, area); 74 | } 75 | 76 | /// Draw the detailed task information modal 77 | pub fn draw_task_details_modal(f: &mut Frame, app: &App) { 78 | if let Some(task) = &app.selected_task_details { 79 | let popup_area = centered_rect(80, 70, f.area()); 80 | 81 | // Clear background 82 | f.render_widget(Clear, popup_area); 83 | 84 | // Draw modal background 85 | f.render_widget( 86 | Block::default() 87 | .borders(Borders::ALL) 88 | .border_style(Style::default().fg(Color::Cyan)) 89 | .title(" Task Details ") 90 | .style(Style::default().bg(Color::Black)), 91 | popup_area, 92 | ); 93 | 94 | let inner_area = Layout::default() 95 | .margin(1) 96 | .constraints([Constraint::Percentage(100)]) 97 | .split(popup_area)[0]; 98 | 99 | // Create task details content 100 | let details_lines = build_task_details_content(task); 101 | 102 | let paragraph = Paragraph::new(details_lines) 103 | .wrap(Wrap { trim: true }) 104 | .scroll((0, 0)); 105 | 106 | f.render_widget(paragraph, inner_area); 107 | } 108 | } 109 | 110 | /// Build the content lines for task details modal 111 | fn build_task_details_content(task: &crate::models::Task) -> Vec { 112 | let mut details_lines = vec![ 113 | Line::from(vec![ 114 | Span::styled( 115 | "ID: ", 116 | Style::default() 117 | .fg(Color::Cyan) 118 | .add_modifier(Modifier::BOLD), 119 | ), 120 | Span::raw(task.id.clone()), 121 | ]), 122 | Line::from(vec![ 123 | Span::styled( 124 | "Name: ", 125 | Style::default() 126 | .fg(Color::Cyan) 127 | .add_modifier(Modifier::BOLD), 128 | ), 129 | Span::raw(task.name.clone()), 130 | ]), 131 | Line::from(vec![ 132 | Span::styled( 133 | "Status: ", 134 | Style::default() 135 | .fg(Color::Cyan) 136 | .add_modifier(Modifier::BOLD), 137 | ), 138 | Span::styled( 139 | format!("{:?}", task.status), 140 | Style::default().fg(get_status_color(&task.status)), 141 | ), 142 | ]), 143 | Line::from(vec![ 144 | Span::styled( 145 | "Worker: ", 146 | Style::default() 147 | .fg(Color::Cyan) 148 | .add_modifier(Modifier::BOLD), 149 | ), 150 | Span::raw(task.worker.as_deref().unwrap_or("Unknown").to_string()), 151 | ]), 152 | Line::from(vec![ 153 | Span::styled( 154 | "Queue: ", 155 | Style::default() 156 | .fg(Color::Cyan) 157 | .add_modifier(Modifier::BOLD), 158 | ), 159 | Span::raw("default".to_string()), 160 | ]), 161 | Line::from(vec![ 162 | Span::styled( 163 | "Timestamp: ", 164 | Style::default() 165 | .fg(Color::Cyan) 166 | .add_modifier(Modifier::BOLD), 167 | ), 168 | Span::raw(task.timestamp.to_string()), 169 | ]), 170 | Line::from(""), 171 | Line::from(vec![Span::styled( 172 | "Arguments: ", 173 | Style::default() 174 | .fg(Color::Cyan) 175 | .add_modifier(Modifier::BOLD), 176 | )]), 177 | Line::from(task.args.as_str()), 178 | Line::from(""), 179 | Line::from(vec![Span::styled( 180 | "Keyword Arguments: ", 181 | Style::default() 182 | .fg(Color::Cyan) 183 | .add_modifier(Modifier::BOLD), 184 | )]), 185 | Line::from(task.kwargs.as_str()), 186 | Line::from(""), 187 | Line::from(vec![Span::styled( 188 | "Result: ", 189 | Style::default() 190 | .fg(Color::Cyan) 191 | .add_modifier(Modifier::BOLD), 192 | )]), 193 | Line::from(task.result.as_deref().unwrap_or("None")), 194 | ]; 195 | 196 | // Add traceback if available and task failed 197 | if task.status == crate::models::TaskStatus::Failure { 198 | if let Some(traceback) = &task.traceback { 199 | details_lines.push(Line::from("")); 200 | details_lines.push(Line::from(vec![Span::styled( 201 | "Traceback: ", 202 | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), 203 | )])); 204 | // Split traceback into lines and add them 205 | for line in traceback.lines() { 206 | details_lines.push(Line::from(Span::styled( 207 | line.to_string(), 208 | Style::default().fg(Color::Red), 209 | ))); 210 | } 211 | } 212 | } 213 | 214 | // Add footer 215 | details_lines.push(Line::from("")); 216 | details_lines.push(Line::from(vec![Span::styled( 217 | "Press any key to close", 218 | Style::default() 219 | .fg(Color::Gray) 220 | .add_modifier(Modifier::ITALIC), 221 | )])); 222 | 223 | details_lines 224 | } 225 | 226 | /// Get the appropriate color for a task status 227 | fn get_status_color(status: &crate::models::TaskStatus) -> Color { 228 | match status { 229 | crate::models::TaskStatus::Success => Color::Green, 230 | crate::models::TaskStatus::Failure => Color::Red, 231 | crate::models::TaskStatus::Retry => Color::Yellow, 232 | crate::models::TaskStatus::Pending => Color::Blue, 233 | crate::models::TaskStatus::Revoked => Color::Magenta, 234 | _ => Color::White, 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/ui/widgets/base.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use ratatui::{layout::Rect, Frame}; 3 | 4 | /// Common trait for all UI widgets that display data with list and details sections 5 | pub trait Widget { 6 | /// Draw the complete widget with its list and details sections 7 | fn draw(f: &mut Frame, app: &App, area: Rect); 8 | 9 | /// Draw the list section (left or top panel) 10 | fn draw_list(f: &mut Frame, app: &App, area: Rect); 11 | 12 | /// Draw the details section (right or bottom panel) 13 | fn draw_details(f: &mut Frame, app: &App, area: Rect); 14 | } 15 | 16 | /// Common helper functions for widget styling and layout 17 | pub mod helpers { 18 | use ratatui::{ 19 | style::{Color, Modifier, Style}, 20 | text::{Line, Span}, 21 | widgets::{Block, BorderType, Borders, Paragraph}, 22 | }; 23 | 24 | /// Create a standard selection style for highlighted items 25 | pub fn selection_style() -> Style { 26 | Style::default() 27 | .bg(Color::DarkGray) 28 | .add_modifier(Modifier::BOLD) 29 | } 30 | 31 | /// Create a standard block with borders and title 32 | pub fn titled_block(title: &str) -> Block { 33 | Block::default() 34 | .borders(Borders::ALL) 35 | .title(format!(" {title} ")) 36 | } 37 | 38 | /// Create a standard "no data" message 39 | pub fn no_data_message(item_type: &str) -> Paragraph { 40 | let message = format!("No {item_type} found"); 41 | let title = format!("{item_type} Details"); 42 | let block = Block::default() 43 | .borders(Borders::ALL) 44 | .border_style(Style::default().fg(Color::Blue)) 45 | .border_type(BorderType::Rounded) 46 | .title(format!(" {title} ")); 47 | Paragraph::new(message).block(block) 48 | } 49 | 50 | /// Create a colored status indicator line 51 | pub fn status_line(label: &str, value: &str, color: Color) -> Line<'static> { 52 | Line::from(vec![ 53 | Span::raw(format!("{label}: ")), 54 | Span::styled(value.to_string(), Style::default().fg(color)), 55 | ]) 56 | } 57 | 58 | /// Create a field line with label and value 59 | pub fn field_line(label: &str, value: &str) -> Line<'static> { 60 | Line::from(vec![ 61 | Span::raw(format!("{label}: ")), 62 | Span::raw(value.to_string()), 63 | ]) 64 | } 65 | 66 | /// Create a highlighted field line (for important values) 67 | pub fn highlighted_field_line(label: &str, value: &str, color: Color) -> Line<'static> { 68 | Line::from(vec![ 69 | Span::raw(format!("{label}: ")), 70 | Span::styled(value.to_string(), Style::default().fg(color)), 71 | ]) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod base; 2 | pub mod queues; 3 | pub mod tasks; 4 | pub mod workers; 5 | 6 | pub use base::Widget; 7 | pub use queues::QueueWidget; 8 | pub use tasks::TaskWidget; 9 | pub use workers::WorkerWidget; 10 | -------------------------------------------------------------------------------- /src/ui/widgets/queues.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Style}, 4 | text::{Line, Span}, 5 | widgets::{Gauge, List, ListItem, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | use super::base::{helpers, Widget}; 10 | use crate::app::App; 11 | 12 | pub struct QueueWidget; 13 | 14 | impl Widget for QueueWidget { 15 | fn draw(f: &mut Frame, app: &App, area: Rect) { 16 | let chunks = Layout::default() 17 | .direction(Direction::Horizontal) 18 | .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) 19 | .split(area); 20 | 21 | // Draw queue list on the left 22 | Self::draw_list(f, app, chunks[0]); 23 | 24 | // Draw queue details on the right 25 | Self::draw_details(f, app, chunks[1]); 26 | } 27 | 28 | fn draw_list(f: &mut Frame, app: &App, area: Rect) { 29 | let queues: Vec = app 30 | .queues 31 | .iter() 32 | .enumerate() 33 | .map(|(idx, queue)| { 34 | let status_color = if queue.length > 100 { 35 | Color::Red 36 | } else if queue.length > 50 { 37 | Color::Yellow 38 | } else { 39 | Color::Green 40 | }; 41 | 42 | let content = Line::from(vec![ 43 | Span::raw(&queue.name), 44 | Span::raw(" "), 45 | Span::styled(queue.length.to_string(), Style::default().fg(status_color)), 46 | ]); 47 | 48 | if idx == app.selected_queue { 49 | ListItem::new(content).style(helpers::selection_style()) 50 | } else { 51 | ListItem::new(content) 52 | } 53 | }) 54 | .collect(); 55 | 56 | let title = format!("Queues ({})", app.queues.len()); 57 | let queues_list = List::new(queues) 58 | .block(helpers::titled_block(&title)) 59 | .highlight_style(helpers::selection_style()); 60 | 61 | f.render_widget(queues_list, area); 62 | } 63 | 64 | fn draw_details(f: &mut Frame, app: &App, area: Rect) { 65 | if app.queues.is_empty() { 66 | f.render_widget(helpers::no_data_message("queues"), area); 67 | return; 68 | } 69 | 70 | if let Some(queue) = app.queues.get(app.selected_queue) { 71 | let chunks = Layout::default() 72 | .direction(Direction::Vertical) 73 | .constraints([ 74 | Constraint::Length(8), 75 | Constraint::Length(3), 76 | Constraint::Min(0), 77 | ]) 78 | .split(area); 79 | 80 | // Queue info 81 | let info_lines = vec![ 82 | helpers::highlighted_field_line("Queue Name", &queue.name, Color::Cyan), 83 | helpers::status_line( 84 | "Messages", 85 | &queue.length.to_string(), 86 | if queue.length > 100 { 87 | Color::Red 88 | } else if queue.length > 50 { 89 | Color::Yellow 90 | } else { 91 | Color::Green 92 | }, 93 | ), 94 | helpers::field_line("Consumers", &queue.consumers.to_string()), 95 | helpers::status_line( 96 | "Status", 97 | if queue.has_consumers() { 98 | "Active" 99 | } else if queue.is_empty() { 100 | "Empty" 101 | } else { 102 | "No consumers" 103 | }, 104 | if queue.has_consumers() { 105 | Color::Green 106 | } else if queue.is_empty() { 107 | Color::Gray 108 | } else { 109 | Color::Yellow 110 | }, 111 | ), 112 | Line::from(""), 113 | Line::from(vec![Span::styled( 114 | "[p] Purge queue (requires confirmation)", 115 | Style::default().fg(Color::DarkGray), 116 | )]), 117 | ]; 118 | 119 | let info = Paragraph::new(info_lines).block(helpers::titled_block("Queue Details")); 120 | f.render_widget(info, chunks[0]); 121 | 122 | // Queue fill gauge 123 | let max_queue_size = 1000; // Configurable max for visualization 124 | let ratio = (queue.length as f64 / max_queue_size as f64).min(1.0); 125 | let gauge = Gauge::default() 126 | .block(helpers::titled_block("Queue Fill")) 127 | .gauge_style(Style::default().fg(if queue.length > 100 { 128 | Color::Red 129 | } else if queue.length > 50 { 130 | Color::Yellow 131 | } else { 132 | Color::Green 133 | })) 134 | .ratio(ratio) 135 | .label(format!("{}/{}", queue.length, max_queue_size)); 136 | f.render_widget(gauge, chunks[1]); 137 | 138 | // Additional info or actions 139 | let actions = Paragraph::new(vec![ 140 | Line::from("Available Actions:"), 141 | Line::from(""), 142 | Line::from("- View messages (coming soon)"), 143 | Line::from("- Purge queue (coming soon)"), 144 | Line::from("- Export messages (coming soon)"), 145 | ]) 146 | .block(helpers::titled_block("Actions")); 147 | f.render_widget(actions, chunks[2]); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/ui/widgets/tasks.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, 6 | Frame, 7 | }; 8 | 9 | use super::base::{helpers, Widget}; 10 | use crate::app::App; 11 | use crate::models::TaskStatus; 12 | use chrono::Utc; 13 | 14 | pub struct TaskWidget; 15 | 16 | impl Widget for TaskWidget { 17 | fn draw(f: &mut Frame, app: &App, area: Rect) { 18 | let chunks = Layout::default() 19 | .direction(Direction::Vertical) 20 | .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 21 | .split(area); 22 | 23 | // Draw task list 24 | Self::draw_list(f, app, chunks[0]); 25 | 26 | // Draw task details 27 | Self::draw_details(f, app, chunks[1]); 28 | } 29 | 30 | fn draw_list(f: &mut Frame, app: &App, area: Rect) { 31 | let filtered_tasks = app.get_filtered_tasks(); 32 | 33 | let header = Row::new(vec!["ID", "Name", "Status", "Worker", "Duration"]) 34 | .style(Style::default().fg(Color::Yellow)) 35 | .bottom_margin(1); 36 | 37 | // Calculate viewport 38 | let height = area.height.saturating_sub(4) as usize; // Account for borders and header 39 | 40 | if filtered_tasks.is_empty() { 41 | let no_tasks = Row::new(vec![ 42 | Cell::from(""), 43 | Cell::from("No tasks found"), 44 | Cell::from(""), 45 | Cell::from(""), 46 | Cell::from(""), 47 | ]) 48 | .style(Style::default().fg(Color::DarkGray)); 49 | 50 | let table = Table::new( 51 | vec![no_tasks], 52 | [ 53 | Constraint::Percentage(20), 54 | Constraint::Percentage(30), 55 | Constraint::Percentage(15), 56 | Constraint::Percentage(20), 57 | Constraint::Percentage(15), 58 | ], 59 | ) 60 | .header(header) 61 | .block(Block::default().borders(Borders::ALL).title(" Tasks (0) ")); 62 | 63 | f.render_widget(table, area); 64 | return; 65 | } 66 | 67 | let selected = app 68 | .selected_task 69 | .min(filtered_tasks.len().saturating_sub(1)); 70 | 71 | // Calculate the start of the viewport to ensure selected item is visible 72 | let start = if selected >= height && height > 0 { 73 | selected.saturating_sub(height / 2) 74 | } else { 75 | 0 76 | }; 77 | 78 | let end = (start + height).min(filtered_tasks.len()); 79 | let visible_tasks = &filtered_tasks[start..end]; 80 | 81 | let rows: Vec = visible_tasks 82 | .iter() 83 | .enumerate() 84 | .map(|(idx, task)| { 85 | let actual_idx = start + idx; 86 | let status_color = match task.status { 87 | TaskStatus::Success => Color::Green, 88 | TaskStatus::Failure => Color::Red, 89 | TaskStatus::Active => Color::Yellow, 90 | TaskStatus::Pending => Color::Gray, 91 | TaskStatus::Retry => Color::Magenta, 92 | TaskStatus::Revoked => Color::DarkGray, 93 | }; 94 | 95 | let duration = task.duration_since(Utc::now()); 96 | let duration_str = format!( 97 | "{:02}:{:02}:{:02}", 98 | duration.num_hours(), 99 | duration.num_minutes() % 60, 100 | duration.num_seconds() % 60 101 | ); 102 | 103 | let row = Row::new(vec![ 104 | Cell::from(task.id.clone()), 105 | Cell::from(task.name.clone()), 106 | Cell::from(format!("{:?}", task.status)) 107 | .style(Style::default().fg(status_color)), 108 | Cell::from(task.worker.as_deref().unwrap_or("-")), 109 | Cell::from(duration_str), 110 | ]); 111 | 112 | if actual_idx == app.selected_task { 113 | row.style(helpers::selection_style()) 114 | } else { 115 | row 116 | } 117 | }) 118 | .collect(); 119 | 120 | // Add scroll indicator to title 121 | let scroll_info = if filtered_tasks.len() > height { 122 | format!(" [{}/{}]", app.selected_task + 1, filtered_tasks.len()) 123 | } else { 124 | String::new() 125 | }; 126 | 127 | let title = if app.is_searching { 128 | format!( 129 | " Tasks (filtered: {}/{}){} ", 130 | filtered_tasks.len(), 131 | app.tasks.len(), 132 | scroll_info 133 | ) 134 | } else { 135 | format!(" Tasks ({}){} ", app.tasks.len(), scroll_info) 136 | }; 137 | 138 | let table = Table::new( 139 | rows, 140 | [ 141 | Constraint::Percentage(20), 142 | Constraint::Percentage(30), 143 | Constraint::Percentage(15), 144 | Constraint::Percentage(20), 145 | Constraint::Percentage(15), 146 | ], 147 | ) 148 | .header(header) 149 | .block(Block::default().borders(Borders::ALL).title(title)) 150 | .row_highlight_style(helpers::selection_style()); 151 | 152 | f.render_widget(table, area); 153 | } 154 | 155 | fn draw_details(f: &mut Frame, app: &App, area: Rect) { 156 | let filtered_tasks = app.get_filtered_tasks(); 157 | 158 | if filtered_tasks.is_empty() { 159 | f.render_widget(helpers::no_data_message("tasks"), area); 160 | return; 161 | } 162 | 163 | let selected = app 164 | .selected_task 165 | .min(filtered_tasks.len().saturating_sub(1)); 166 | if let Some(task) = filtered_tasks.get(selected) { 167 | let mut lines = vec![ 168 | helpers::highlighted_field_line("ID", &task.id, Color::Cyan), 169 | helpers::highlighted_field_line("Name", &task.name, Color::Yellow), 170 | helpers::status_line( 171 | "Status", 172 | &format!("{:?}", task.status), 173 | match task.status { 174 | TaskStatus::Success => Color::Green, 175 | TaskStatus::Failure => Color::Red, 176 | TaskStatus::Active => Color::Yellow, 177 | TaskStatus::Pending => Color::Gray, 178 | TaskStatus::Retry => Color::Magenta, 179 | TaskStatus::Revoked => Color::DarkGray, 180 | }, 181 | ), 182 | helpers::field_line("Worker", task.worker.as_deref().unwrap_or("None")), 183 | helpers::field_line( 184 | "Timestamp", 185 | &task.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(), 186 | ), 187 | ]; 188 | 189 | if !task.args.is_empty() && task.args != "[]" { 190 | lines.push(helpers::field_line("Args", &task.args)); 191 | } 192 | 193 | if !task.kwargs.is_empty() && task.kwargs != "{}" { 194 | lines.push(helpers::field_line("Kwargs", &task.kwargs)); 195 | } 196 | 197 | if let Some(result) = &task.result { 198 | lines.push(Line::from("")); 199 | lines.push(helpers::highlighted_field_line( 200 | "Result", 201 | result, 202 | Color::Green, 203 | )); 204 | } 205 | 206 | if let Some(traceback) = &task.traceback { 207 | lines.push(Line::from("")); 208 | lines.push(Line::from(vec![Span::styled( 209 | "Traceback:", 210 | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), 211 | )])); 212 | for line in traceback.lines() { 213 | lines.push(Line::from(vec![Span::styled( 214 | line, 215 | Style::default().fg(Color::Red), 216 | )])); 217 | } 218 | } 219 | 220 | let details = Paragraph::new(lines) 221 | .block(helpers::titled_block("Task Details")) 222 | .wrap(Wrap { trim: false }); 223 | 224 | f.render_widget(details, area); 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/ui/widgets/workers.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Style}, 4 | text::{Line, Span}, 5 | widgets::{List, ListItem, Paragraph, Row, Table}, 6 | Frame, 7 | }; 8 | 9 | use super::base::{helpers, Widget}; 10 | use crate::app::App; 11 | use crate::models::WorkerStatus; 12 | 13 | pub struct WorkerWidget; 14 | 15 | impl Widget for WorkerWidget { 16 | fn draw(f: &mut Frame, app: &App, area: Rect) { 17 | let chunks = Layout::default() 18 | .direction(Direction::Horizontal) 19 | .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) 20 | .split(area); 21 | 22 | // Draw worker list on the left 23 | Self::draw_list(f, app, chunks[0]); 24 | 25 | // Draw worker details on the right 26 | Self::draw_details(f, app, chunks[1]); 27 | } 28 | 29 | fn draw_list(f: &mut Frame, app: &App, area: Rect) { 30 | let workers: Vec = app 31 | .workers 32 | .iter() 33 | .enumerate() 34 | .map(|(idx, worker)| { 35 | let status_symbol = match worker.status { 36 | WorkerStatus::Online => "●", 37 | WorkerStatus::Offline => "○", 38 | }; 39 | let status_color = match worker.status { 40 | WorkerStatus::Online => Color::Green, 41 | WorkerStatus::Offline => Color::Red, 42 | }; 43 | 44 | let content = Line::from(vec![ 45 | Span::styled(status_symbol, Style::default().fg(status_color)), 46 | Span::raw(" "), 47 | Span::raw(&worker.hostname), 48 | ]); 49 | 50 | if idx == app.selected_worker { 51 | ListItem::new(content).style(helpers::selection_style()) 52 | } else { 53 | ListItem::new(content) 54 | } 55 | }) 56 | .collect(); 57 | 58 | let title = format!("Workers ({})", app.workers.len()); 59 | let workers_list = List::new(workers) 60 | .block(helpers::titled_block(&title)) 61 | .highlight_style(helpers::selection_style()); 62 | 63 | f.render_widget(workers_list, area); 64 | } 65 | 66 | fn draw_details(f: &mut Frame, app: &App, area: Rect) { 67 | if app.workers.is_empty() { 68 | f.render_widget(helpers::no_data_message("workers"), area); 69 | return; 70 | } 71 | 72 | if let Some(worker) = app.workers.get(app.selected_worker) { 73 | let chunks = Layout::default() 74 | .direction(Direction::Vertical) 75 | .constraints([Constraint::Length(10), Constraint::Min(0)]) 76 | .split(area); 77 | 78 | // Worker info section 79 | let info_lines = vec![ 80 | helpers::highlighted_field_line("Hostname", &worker.hostname, Color::Cyan), 81 | helpers::status_line( 82 | "Status", 83 | match worker.status { 84 | WorkerStatus::Online => "Online", 85 | WorkerStatus::Offline => "Offline", 86 | }, 87 | match worker.status { 88 | WorkerStatus::Online => Color::Green, 89 | WorkerStatus::Offline => Color::Red, 90 | }, 91 | ), 92 | helpers::field_line("Concurrency", &worker.concurrency.to_string()), 93 | helpers::field_line( 94 | "Active Tasks", 95 | &format!("{}/{}", worker.active_tasks.len(), worker.concurrency), 96 | ), 97 | helpers::field_line("Utilization", &format!("{:.1}%", worker.utilization())), 98 | helpers::highlighted_field_line( 99 | "Processed", 100 | &worker.processed.to_string(), 101 | Color::Green, 102 | ), 103 | helpers::highlighted_field_line("Failed", &worker.failed.to_string(), Color::Red), 104 | helpers::field_line("Queues", &worker.queues.join(", ")), 105 | ]; 106 | 107 | let info = Paragraph::new(info_lines).block(helpers::titled_block("Worker Details")); 108 | f.render_widget(info, chunks[0]); 109 | 110 | // Active tasks section 111 | if !worker.active_tasks.is_empty() { 112 | let task_rows: Vec = worker 113 | .active_tasks 114 | .iter() 115 | .map(|task_id| Row::new(vec![task_id.clone()])) 116 | .collect(); 117 | 118 | let tasks_table = Table::new(task_rows, [Constraint::Percentage(100)]) 119 | .block(helpers::titled_block("Active Tasks")) 120 | .header( 121 | Row::new(vec!["Task ID"]) 122 | .style(Style::default().fg(Color::Yellow)) 123 | .bottom_margin(1), 124 | ); 125 | 126 | f.render_widget(tasks_table, chunks[1]); 127 | } else { 128 | let no_tasks = 129 | Paragraph::new("No active tasks").block(helpers::titled_block("Active Tasks")); 130 | f.render_widget(no_tasks, chunks[1]); 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/utils/formatting.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Duration, Utc}; 2 | 3 | /// Format duration as HH:MM:SS or MM:SS - utility function for future UI features 4 | #[allow(dead_code)] 5 | pub fn format_duration(duration: Duration) -> String { 6 | let hours = duration.num_hours(); 7 | let minutes = duration.num_minutes() % 60; 8 | let seconds = duration.num_seconds() % 60; 9 | 10 | if hours > 0 { 11 | format!("{hours:02}:{minutes:02}:{seconds:02}") 12 | } else { 13 | format!("{minutes:02}:{seconds:02}") 14 | } 15 | } 16 | 17 | /// Format timestamp for display - utility function for future UI features 18 | #[allow(dead_code)] 19 | pub fn format_timestamp(timestamp: DateTime) -> String { 20 | timestamp.format("%Y-%m-%d %H:%M:%S").to_string() 21 | } 22 | 23 | /// Truncate string with ellipsis - utility function for UI text overflow 24 | #[allow(dead_code)] 25 | pub fn truncate_string(s: &str, max_len: usize) -> String { 26 | if s.len() <= max_len { 27 | s.to_string() 28 | } else if max_len <= 3 { 29 | "...".to_string() 30 | } else { 31 | format!("{}...", &s[..max_len - 3]) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod formatting; 2 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::app::App; 2 | 3 | mod test_broker_utils; 4 | use test_broker_utils::MockBrokerBuilder; 5 | 6 | #[test] 7 | fn test_navigation_and_selection() { 8 | let broker = MockBrokerBuilder::for_integration_tests(); 9 | let mut app = App::new(broker); 10 | 11 | // Test initial state 12 | assert_eq!(app.selected_worker, 0); 13 | assert_eq!(app.selected_task, 0); 14 | assert_eq!(app.selected_queue, 0); 15 | 16 | // Test tab navigation 17 | app.next_tab(); 18 | app.next_tab(); 19 | 20 | // Test item selection 21 | app.select_next(); 22 | app.select_previous(); 23 | 24 | // Verify state is maintained 25 | assert_eq!(app.selected_worker, 0); 26 | } 27 | 28 | #[tokio::test] 29 | async fn test_full_application_flow() { 30 | let broker = MockBrokerBuilder::for_integration_tests(); 31 | let mut app = App::new(broker); 32 | 33 | // Test data refresh 34 | app.refresh_data().await.unwrap(); 35 | 36 | // Verify we have the expected integration test data 37 | assert_eq!(app.workers.len(), 3); 38 | assert_eq!(app.tasks.len(), 5); 39 | assert_eq!(app.queues.len(), 4); 40 | 41 | // Verify specific worker data 42 | assert_eq!(app.workers[0].hostname, "celery@worker-prod-1"); 43 | assert_eq!(app.workers[0].processed, 15234); 44 | 45 | // Verify specific task data 46 | assert_eq!(app.tasks[0].id, "task-001"); 47 | assert_eq!(app.tasks[0].name, "app.tasks.send_welcome_email"); 48 | 49 | // Verify specific queue data 50 | assert_eq!(app.queues[0].name, "default"); 51 | assert_eq!(app.queues[0].length, 42); 52 | 53 | // Test navigation: Workers -> Queues -> Tasks -> Queues 54 | app.next_tab(); // Workers -> Queues 55 | app.next_tab(); // Queues -> Tasks 56 | app.previous_tab(); // Tasks -> Queues 57 | 58 | // Should be on Queues tab now, so test queue selection 59 | app.select_next(); 60 | assert_eq!(app.selected_queue, 1); 61 | 62 | app.select_previous(); 63 | assert_eq!(app.selected_queue, 0); 64 | 65 | // Go to Tasks tab to test task selection 66 | app.next_tab(); // Queues -> Tasks 67 | app.select_next(); 68 | assert_eq!(app.selected_task, 1); 69 | 70 | app.select_previous(); 71 | assert_eq!(app.selected_task, 0); 72 | 73 | // Go back to Workers tab to test worker selection 74 | app.next_tab(); // Tasks -> Workers 75 | app.select_next(); 76 | assert_eq!(app.selected_worker, 1); 77 | 78 | app.select_previous(); 79 | assert_eq!(app.selected_worker, 0); 80 | } 81 | -------------------------------------------------------------------------------- /tests/test_app.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::app::{App, Tab}; 2 | use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus}; 3 | 4 | mod test_broker_utils; 5 | use test_broker_utils::MockBrokerBuilder; 6 | 7 | #[test] 8 | fn test_app_creation() { 9 | let broker = MockBrokerBuilder::empty().build(); 10 | let app = App::new(broker); 11 | 12 | assert_eq!(app.selected_tab, Tab::Workers); 13 | assert!(!app.should_quit); 14 | assert_eq!(app.selected_worker, 0); 15 | assert_eq!(app.selected_task, 0); 16 | assert_eq!(app.selected_queue, 0); 17 | assert!(!app.show_help); 18 | assert!(!app.is_searching); 19 | assert_eq!(app.search_query, ""); 20 | } 21 | 22 | #[test] 23 | fn test_tab_navigation() { 24 | let broker = MockBrokerBuilder::empty().build(); 25 | let mut app = App::new(broker); 26 | 27 | assert_eq!(app.selected_tab, Tab::Workers); 28 | 29 | app.next_tab(); 30 | assert_eq!(app.selected_tab, Tab::Queues); 31 | 32 | app.next_tab(); 33 | assert_eq!(app.selected_tab, Tab::Tasks); 34 | 35 | app.next_tab(); 36 | assert_eq!(app.selected_tab, Tab::Workers); 37 | 38 | app.previous_tab(); 39 | assert_eq!(app.selected_tab, Tab::Tasks); 40 | 41 | app.previous_tab(); 42 | assert_eq!(app.selected_tab, Tab::Queues); 43 | 44 | app.previous_tab(); 45 | assert_eq!(app.selected_tab, Tab::Workers); 46 | } 47 | 48 | #[tokio::test] 49 | async fn test_app_refresh_data() { 50 | let test_workers = vec![Worker { 51 | hostname: "worker-1".to_string(), 52 | status: WorkerStatus::Online, 53 | concurrency: 4, 54 | queues: vec!["default".to_string()], 55 | active_tasks: vec![], 56 | processed: 100, 57 | failed: 5, 58 | }]; 59 | 60 | let test_tasks = vec![Task { 61 | id: "task-1".to_string(), 62 | name: "send_email".to_string(), 63 | args: "[]".to_string(), 64 | kwargs: "{}".to_string(), 65 | status: TaskStatus::Success, 66 | worker: Some("worker-1".to_string()), 67 | timestamp: chrono::Utc::now(), 68 | result: None, 69 | traceback: None, 70 | }]; 71 | 72 | let test_queues = vec![Queue { 73 | name: "default".to_string(), 74 | length: 10, 75 | consumers: 2, 76 | }]; 77 | 78 | let broker = MockBrokerBuilder::new() 79 | .with_workers(test_workers.clone()) 80 | .with_tasks(test_tasks.clone()) 81 | .with_queues(test_queues.clone()) 82 | .build(); 83 | 84 | let mut app = App::new(broker); 85 | 86 | app.refresh_data().await.unwrap(); 87 | 88 | assert_eq!(app.workers.len(), 1); 89 | assert_eq!(app.tasks.len(), 1); 90 | assert_eq!(app.queues.len(), 1); 91 | assert_eq!(app.workers[0].hostname, "worker-1"); 92 | assert_eq!(app.tasks[0].id, "task-1"); 93 | assert_eq!(app.queues[0].name, "default"); 94 | } 95 | 96 | #[test] 97 | fn test_item_selection() { 98 | let broker = MockBrokerBuilder::new() 99 | .with_workers(vec![ 100 | Worker { 101 | hostname: "worker-1".to_string(), 102 | status: WorkerStatus::Online, 103 | concurrency: 4, 104 | queues: vec![], 105 | active_tasks: vec![], 106 | processed: 0, 107 | failed: 0, 108 | }, 109 | Worker { 110 | hostname: "worker-2".to_string(), 111 | status: WorkerStatus::Online, 112 | concurrency: 4, 113 | queues: vec![], 114 | active_tasks: vec![], 115 | processed: 0, 116 | failed: 0, 117 | }, 118 | ]) 119 | .build(); 120 | 121 | let mut app = App::new(broker); 122 | app.workers = vec![ 123 | Worker { 124 | hostname: "worker-1".to_string(), 125 | status: WorkerStatus::Online, 126 | concurrency: 4, 127 | queues: vec![], 128 | active_tasks: vec![], 129 | processed: 0, 130 | failed: 0, 131 | }, 132 | Worker { 133 | hostname: "worker-2".to_string(), 134 | status: WorkerStatus::Online, 135 | concurrency: 4, 136 | queues: vec![], 137 | active_tasks: vec![], 138 | processed: 0, 139 | failed: 0, 140 | }, 141 | ]; 142 | 143 | assert_eq!(app.selected_worker, 0); 144 | 145 | app.select_next(); 146 | assert_eq!(app.selected_worker, 1); 147 | 148 | app.select_next(); 149 | assert_eq!(app.selected_worker, 0); // Wraps around 150 | 151 | app.select_previous(); 152 | assert_eq!(app.selected_worker, 1); // Wraps around 153 | 154 | app.select_previous(); 155 | assert_eq!(app.selected_worker, 0); 156 | } 157 | 158 | #[test] 159 | fn test_help_toggle() { 160 | let broker = MockBrokerBuilder::empty().build(); 161 | let mut app = App::new(broker); 162 | 163 | assert!(!app.show_help); 164 | 165 | app.toggle_help(); 166 | assert!(app.show_help); 167 | 168 | app.toggle_help(); 169 | assert!(!app.show_help); 170 | } 171 | 172 | #[test] 173 | fn test_search_functionality() { 174 | let broker = MockBrokerBuilder::empty().build(); 175 | let mut app = App::new(broker); 176 | app.tasks = vec![ 177 | Task { 178 | id: "abc123".to_string(), 179 | name: "send_email".to_string(), 180 | args: "[]".to_string(), 181 | kwargs: "{}".to_string(), 182 | status: TaskStatus::Success, 183 | worker: None, 184 | timestamp: chrono::Utc::now(), 185 | result: None, 186 | traceback: None, 187 | }, 188 | Task { 189 | id: "def456".to_string(), 190 | name: "process_data".to_string(), 191 | args: "[]".to_string(), 192 | kwargs: "{}".to_string(), 193 | status: TaskStatus::Success, 194 | worker: None, 195 | timestamp: chrono::Utc::now(), 196 | result: None, 197 | traceback: None, 198 | }, 199 | ]; 200 | 201 | assert!(!app.is_searching); 202 | assert_eq!(app.search_query, ""); 203 | 204 | app.start_search(); 205 | assert!(app.is_searching); 206 | assert_eq!(app.search_query, ""); 207 | 208 | app.search_query = "email".to_string(); 209 | let filtered = app.get_filtered_tasks(); 210 | assert_eq!(filtered.len(), 1); 211 | assert_eq!(filtered[0].name, "send_email"); 212 | 213 | app.search_query = "abc".to_string(); 214 | let filtered = app.get_filtered_tasks(); 215 | assert_eq!(filtered.len(), 1); 216 | assert_eq!(filtered[0].id, "abc123"); 217 | 218 | app.stop_search(); 219 | assert!(!app.is_searching); 220 | assert_eq!(app.search_query, ""); 221 | assert_eq!(app.get_filtered_tasks().len(), 2); 222 | } 223 | 224 | #[test] 225 | fn test_empty_state_selection() { 226 | let broker = MockBrokerBuilder::empty().build(); 227 | let mut app = App::new(broker); 228 | 229 | // Should not panic when selecting with empty lists 230 | app.select_next(); 231 | app.select_previous(); 232 | 233 | assert_eq!(app.selected_worker, 0); 234 | assert_eq!(app.selected_task, 0); 235 | assert_eq!(app.selected_queue, 0); 236 | } 237 | -------------------------------------------------------------------------------- /tests/test_config.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::config::{BrokerConfig, Config, UiConfig}; 2 | use std::fs; 3 | use std::path::PathBuf; 4 | use tempfile::tempdir; 5 | 6 | #[test] 7 | fn test_default_config() { 8 | let config = Config::default(); 9 | 10 | assert_eq!(config.broker.url, "redis://localhost:6379/0"); 11 | assert_eq!(config.broker.timeout, 30); 12 | assert_eq!(config.broker.retry_attempts, 3); 13 | assert_eq!(config.ui.refresh_interval, 1000); 14 | assert_eq!(config.ui.theme, "dark"); 15 | } 16 | 17 | #[test] 18 | fn test_config_from_file() { 19 | let dir = tempdir().unwrap(); 20 | let config_path = dir.path().join("test_config.toml"); 21 | 22 | let config_content = r#" 23 | [broker] 24 | url = "redis://192.168.1.100:6379/1" 25 | timeout = 60 26 | retry_attempts = 5 27 | 28 | [ui] 29 | refresh_interval = 2000 30 | theme = "light" 31 | "#; 32 | 33 | fs::write(&config_path, config_content).unwrap(); 34 | 35 | let config = Config::from_file(config_path).unwrap(); 36 | 37 | assert_eq!(config.broker.url, "redis://192.168.1.100:6379/1"); 38 | assert_eq!(config.broker.timeout, 60); 39 | assert_eq!(config.broker.retry_attempts, 5); 40 | assert_eq!(config.ui.refresh_interval, 2000); 41 | assert_eq!(config.ui.theme, "light"); 42 | } 43 | 44 | #[test] 45 | fn test_partial_config_from_file() { 46 | let dir = tempdir().unwrap(); 47 | let config_path = dir.path().join("partial_config.toml"); 48 | 49 | let config_content = r#" 50 | [broker] 51 | url = "redis://custom:6379/0" 52 | 53 | [ui] 54 | refresh_interval = 500 55 | "#; 56 | 57 | fs::write(&config_path, config_content).unwrap(); 58 | 59 | // This should fail because required fields are missing 60 | let result = Config::from_file(config_path); 61 | assert!(result.is_err()); 62 | } 63 | 64 | #[test] 65 | fn test_invalid_config_file() { 66 | let dir = tempdir().unwrap(); 67 | let config_path = dir.path().join("invalid_config.toml"); 68 | 69 | let config_content = "invalid toml content {{"; 70 | 71 | fs::write(&config_path, config_content).unwrap(); 72 | 73 | let result = Config::from_file(config_path); 74 | assert!(result.is_err()); 75 | } 76 | 77 | #[test] 78 | fn test_nonexistent_config_file() { 79 | let config_path = PathBuf::from("/nonexistent/path/config.toml"); 80 | let result = Config::from_file(config_path); 81 | assert!(result.is_err()); 82 | } 83 | 84 | #[test] 85 | fn test_config_serialization() { 86 | let config = Config { 87 | broker: BrokerConfig { 88 | url: "amqp://guest:guest@localhost:5672//".to_string(), 89 | timeout: 45, 90 | retry_attempts: 2, 91 | }, 92 | ui: UiConfig { 93 | refresh_interval: 3000, 94 | theme: "custom".to_string(), 95 | }, 96 | }; 97 | 98 | let toml_str = toml::to_string(&config).unwrap(); 99 | let deserialized: Config = toml::from_str(&toml_str).unwrap(); 100 | 101 | assert_eq!(config.broker.url, deserialized.broker.url); 102 | assert_eq!(config.broker.timeout, deserialized.broker.timeout); 103 | assert_eq!(config.ui.refresh_interval, deserialized.ui.refresh_interval); 104 | assert_eq!(config.ui.theme, deserialized.ui.theme); 105 | } 106 | -------------------------------------------------------------------------------- /tests/test_error.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::error::{AppError, BrokerError}; 2 | 3 | #[test] 4 | fn test_broker_error_display() { 5 | let err = BrokerError::ConnectionError("Failed to connect".to_string()); 6 | assert_eq!(err.to_string(), "Connection failed: Failed to connect"); 7 | 8 | let err = BrokerError::AuthError; 9 | assert_eq!(err.to_string(), "Authentication failed"); 10 | 11 | let err = BrokerError::OperationError("Operation failed".to_string()); 12 | assert_eq!(err.to_string(), "Broker operation failed: Operation failed"); 13 | 14 | let err = BrokerError::InvalidUrl("bad url".to_string()); 15 | assert_eq!(err.to_string(), "Invalid broker URL: bad url"); 16 | 17 | let err = BrokerError::Timeout; 18 | assert_eq!(err.to_string(), "Timeout occurred"); 19 | 20 | let err = BrokerError::NotImplemented; 21 | assert_eq!(err.to_string(), "Not implemented"); 22 | } 23 | 24 | #[test] 25 | fn test_app_error_from_broker_error() { 26 | let broker_err = BrokerError::ConnectionError("test".to_string()); 27 | let app_err: AppError = broker_err.into(); 28 | 29 | match app_err { 30 | AppError::Broker(_) => {} 31 | _ => panic!("Expected AppError::Broker variant"), 32 | } 33 | } 34 | 35 | #[test] 36 | fn test_app_error_display() { 37 | let broker_err = BrokerError::AuthError; 38 | let app_err = AppError::Broker(broker_err); 39 | assert_eq!(app_err.to_string(), "Broker error: Authentication failed"); 40 | 41 | let app_err = AppError::Ui("UI crashed".to_string()); 42 | assert_eq!(app_err.to_string(), "UI error: UI crashed"); 43 | 44 | let app_err = AppError::Config("Invalid config".to_string()); 45 | assert_eq!(app_err.to_string(), "Configuration error: Invalid config"); 46 | } 47 | -------------------------------------------------------------------------------- /tests/test_models.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus}; 3 | 4 | #[test] 5 | fn test_worker_creation() { 6 | let worker = Worker { 7 | hostname: "test-worker".to_string(), 8 | status: WorkerStatus::Online, 9 | concurrency: 4, 10 | queues: vec!["default".to_string()], 11 | active_tasks: vec![], 12 | processed: 100, 13 | failed: 5, 14 | }; 15 | 16 | assert_eq!(worker.hostname, "test-worker"); 17 | assert_eq!(worker.status, WorkerStatus::Online); 18 | assert_eq!(worker.concurrency, 4); 19 | assert_eq!(worker.utilization(), 0.0); 20 | } 21 | 22 | #[test] 23 | fn test_worker_utilization() { 24 | let mut worker = Worker { 25 | hostname: "test-worker".to_string(), 26 | status: WorkerStatus::Online, 27 | concurrency: 4, 28 | queues: vec![], 29 | active_tasks: vec!["task1".to_string(), "task2".to_string()], 30 | processed: 0, 31 | failed: 0, 32 | }; 33 | 34 | assert_eq!(worker.utilization(), 50.0); 35 | 36 | worker.active_tasks.push("task3".to_string()); 37 | worker.active_tasks.push("task4".to_string()); 38 | assert_eq!(worker.utilization(), 100.0); 39 | 40 | // Test edge case: zero concurrency 41 | worker.concurrency = 0; 42 | assert_eq!(worker.utilization(), 0.0); 43 | } 44 | 45 | #[test] 46 | fn test_worker_serialization() { 47 | let worker = Worker { 48 | hostname: "worker-1".to_string(), 49 | status: WorkerStatus::Online, 50 | concurrency: 2, 51 | queues: vec!["queue1".to_string()], 52 | active_tasks: vec![], 53 | processed: 50, 54 | failed: 2, 55 | }; 56 | 57 | let json = serde_json::to_string(&worker).unwrap(); 58 | let deserialized: Worker = serde_json::from_str(&json).unwrap(); 59 | 60 | assert_eq!(worker.hostname, deserialized.hostname); 61 | assert_eq!(worker.processed, deserialized.processed); 62 | } 63 | 64 | #[test] 65 | fn test_task_creation() { 66 | let task = Task { 67 | id: "abc123".to_string(), 68 | name: "send_email".to_string(), 69 | args: "[]".to_string(), 70 | kwargs: "{}".to_string(), 71 | status: TaskStatus::Active, 72 | worker: Some("worker-1".to_string()), 73 | timestamp: Utc::now(), 74 | result: None, 75 | traceback: None, 76 | }; 77 | 78 | assert_eq!(task.id, "abc123"); 79 | assert_eq!(task.name, "send_email"); 80 | assert_eq!(task.status, TaskStatus::Active); 81 | } 82 | 83 | #[test] 84 | fn test_task_duration() { 85 | let past_time = Utc::now() - chrono::Duration::minutes(5); 86 | let task = Task { 87 | id: "test".to_string(), 88 | name: "test_task".to_string(), 89 | args: "[]".to_string(), 90 | kwargs: "{}".to_string(), 91 | status: TaskStatus::Success, 92 | worker: None, 93 | timestamp: past_time, 94 | result: None, 95 | traceback: None, 96 | }; 97 | 98 | let duration = task.duration_since(Utc::now()); 99 | assert!(duration.num_minutes() >= 4); 100 | assert!(duration.num_minutes() <= 6); 101 | } 102 | 103 | #[test] 104 | fn test_task_serialization() { 105 | let task = Task { 106 | id: "123".to_string(), 107 | name: "process_data".to_string(), 108 | args: "[1, 2, 3]".to_string(), 109 | kwargs: r#"{"key": "value"}"#.to_string(), 110 | status: TaskStatus::Failure, 111 | worker: Some("worker-2".to_string()), 112 | timestamp: Utc::now(), 113 | result: Some("error result".to_string()), 114 | traceback: Some("traceback here".to_string()), 115 | }; 116 | 117 | let json = serde_json::to_string(&task).unwrap(); 118 | let deserialized: Task = serde_json::from_str(&json).unwrap(); 119 | 120 | assert_eq!(task.id, deserialized.id); 121 | assert_eq!(task.status, deserialized.status); 122 | assert_eq!(task.traceback, deserialized.traceback); 123 | } 124 | 125 | #[test] 126 | fn test_queue_creation() { 127 | let queue = Queue { 128 | name: "default".to_string(), 129 | length: 42, 130 | consumers: 3, 131 | }; 132 | 133 | assert_eq!(queue.name, "default"); 134 | assert_eq!(queue.length, 42); 135 | assert!(!queue.is_empty()); 136 | assert!(queue.has_consumers()); 137 | } 138 | 139 | #[test] 140 | fn test_queue_empty_state() { 141 | let queue = Queue { 142 | name: "empty".to_string(), 143 | length: 0, 144 | consumers: 0, 145 | }; 146 | 147 | assert!(queue.is_empty()); 148 | assert!(!queue.has_consumers()); 149 | } 150 | 151 | #[test] 152 | fn test_queue_serialization() { 153 | let queue = Queue { 154 | name: "priority".to_string(), 155 | length: 100, 156 | consumers: 5, 157 | }; 158 | 159 | let json = serde_json::to_string(&queue).unwrap(); 160 | let deserialized: Queue = serde_json::from_str(&json).unwrap(); 161 | 162 | assert_eq!(queue.name, deserialized.name); 163 | assert_eq!(queue.length, deserialized.length); 164 | assert_eq!(queue.consumers, deserialized.consumers); 165 | } 166 | 167 | #[test] 168 | fn test_task_status_variants() { 169 | let statuses = vec![ 170 | TaskStatus::Pending, 171 | TaskStatus::Active, 172 | TaskStatus::Success, 173 | TaskStatus::Failure, 174 | TaskStatus::Retry, 175 | TaskStatus::Revoked, 176 | ]; 177 | 178 | for status in statuses { 179 | let json = serde_json::to_string(&status).unwrap(); 180 | let deserialized: TaskStatus = serde_json::from_str(&json).unwrap(); 181 | assert_eq!(status, deserialized); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/test_ui_base_widgets.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::ui::widgets::base::helpers::*; 2 | use ratatui::style::{Color, Modifier, Style}; 3 | 4 | #[test] 5 | fn test_selection_style() { 6 | let style = selection_style(); 7 | 8 | assert_eq!(style.bg, Some(Color::DarkGray)); 9 | assert!(style.add_modifier.contains(Modifier::BOLD)); 10 | } 11 | 12 | #[test] 13 | fn test_titled_block() { 14 | let _block = titled_block("Test Title"); 15 | 16 | // Test that the function runs without panicking 17 | // The actual title format is " Test Title " (with spaces) 18 | // Function executed successfully if we reach this point 19 | } 20 | 21 | #[test] 22 | fn test_titled_block_different_titles() { 23 | let test_titles = vec![ 24 | "Workers", 25 | "Queues", 26 | "Tasks", 27 | "Details", 28 | "Very Long Title With Spaces", 29 | "", 30 | "Title with 123 numbers", 31 | ]; 32 | 33 | for title in test_titles { 34 | let _block = titled_block(title); 35 | // Test that each call completes successfully 36 | // No assertion needed - function success is implicit 37 | } 38 | } 39 | 40 | #[test] 41 | fn test_no_data_message() { 42 | let _paragraph = no_data_message("workers"); 43 | 44 | // The paragraph is created successfully 45 | // We can't easily inspect the exact text content, but we can verify structure 46 | // The function should create a paragraph with a border and title 47 | 48 | // Test with different item types 49 | let item_types = vec!["workers", "tasks", "queues", "results"]; 50 | 51 | for item_type in item_types { 52 | let _paragraph = no_data_message(item_type); 53 | // Each call should succeed without panicking 54 | // No assertion needed - function success is implicit 55 | } 56 | } 57 | 58 | #[test] 59 | fn test_status_line() { 60 | let line = status_line("Status", "Online", Color::Green); 61 | 62 | // Verify the line contains both label and value spans 63 | assert_eq!(line.spans.len(), 2); 64 | 65 | // First span should be the label with colon 66 | assert_eq!(line.spans[0].content, "Status: "); 67 | 68 | // Second span should be the value with color 69 | assert_eq!(line.spans[1].content, "Online"); 70 | assert_eq!(line.spans[1].style.fg, Some(Color::Green)); 71 | } 72 | 73 | #[test] 74 | fn test_status_line_different_colors() { 75 | let test_cases = vec![ 76 | ("Active", "Running", Color::Green), 77 | ("Failed", "Error", Color::Red), 78 | ("Pending", "Waiting", Color::Yellow), 79 | ("Unknown", "N/A", Color::Gray), 80 | ]; 81 | 82 | for (label, value, color) in test_cases { 83 | let line = status_line(label, value, color); 84 | 85 | assert_eq!(line.spans.len(), 2); 86 | assert_eq!(line.spans[0].content, format!("{label}: ")); 87 | assert_eq!(line.spans[1].content, value); 88 | assert_eq!(line.spans[1].style.fg, Some(color)); 89 | } 90 | } 91 | 92 | #[test] 93 | fn test_field_line() { 94 | let line = field_line("Name", "test-worker"); 95 | 96 | assert_eq!(line.spans.len(), 2); 97 | assert_eq!(line.spans[0].content, "Name: "); 98 | assert_eq!(line.spans[1].content, "test-worker"); 99 | 100 | // Both spans should have default styling (no specific color) 101 | assert_eq!(line.spans[0].style, Style::default()); 102 | assert_eq!(line.spans[1].style, Style::default()); 103 | } 104 | 105 | #[test] 106 | fn test_field_line_edge_cases() { 107 | let test_cases = vec![ 108 | ("", ""), 109 | ("Empty Value", ""), 110 | ("", "Empty Label"), 111 | ("Long Label Name", "Short"), 112 | ("ID", "very-long-id-string-with-many-characters-123456789"), 113 | ("Special/Chars!", "Value@#$%^&*()"), 114 | ]; 115 | 116 | for (label, value) in test_cases { 117 | let line = field_line(label, value); 118 | 119 | assert_eq!(line.spans.len(), 2); 120 | assert_eq!(line.spans[0].content, format!("{label}: ")); 121 | assert_eq!(line.spans[1].content, value); 122 | } 123 | } 124 | 125 | #[test] 126 | fn test_highlighted_field_line() { 127 | let line = highlighted_field_line("Priority", "High", Color::Red); 128 | 129 | assert_eq!(line.spans.len(), 2); 130 | assert_eq!(line.spans[0].content, "Priority: "); 131 | assert_eq!(line.spans[1].content, "High"); 132 | assert_eq!(line.spans[1].style.fg, Some(Color::Red)); 133 | 134 | // First span should be default style 135 | assert_eq!(line.spans[0].style, Style::default()); 136 | } 137 | 138 | #[test] 139 | fn test_highlighted_field_line_various_colors() { 140 | let test_cases = vec![ 141 | ("Error", "Critical", Color::Red), 142 | ("Success", "Completed", Color::Green), 143 | ("Warning", "Attention", Color::Yellow), 144 | ("Info", "Details", Color::Blue), 145 | ("Debug", "Verbose", Color::Magenta), 146 | ]; 147 | 148 | for (label, value, color) in test_cases { 149 | let line = highlighted_field_line(label, value, color); 150 | 151 | assert_eq!(line.spans.len(), 2); 152 | assert_eq!(line.spans[0].content, format!("{label}: ")); 153 | assert_eq!(line.spans[1].content, value); 154 | assert_eq!(line.spans[1].style.fg, Some(color)); 155 | } 156 | } 157 | 158 | #[test] 159 | fn test_line_span_consistency() { 160 | // Test that all line creation functions produce consistent span structures 161 | 162 | let status_line_result = status_line("Test", "Value", Color::White); 163 | let field_line_result = field_line("Test", "Value"); 164 | let highlighted_line_result = highlighted_field_line("Test", "Value", Color::White); 165 | 166 | // All should have exactly 2 spans 167 | assert_eq!(status_line_result.spans.len(), 2); 168 | assert_eq!(field_line_result.spans.len(), 2); 169 | assert_eq!(highlighted_line_result.spans.len(), 2); 170 | 171 | // All should have the same label format 172 | assert_eq!(status_line_result.spans[0].content, "Test: "); 173 | assert_eq!(field_line_result.spans[0].content, "Test: "); 174 | assert_eq!(highlighted_line_result.spans[0].content, "Test: "); 175 | 176 | // All should have the same value content 177 | assert_eq!(status_line_result.spans[1].content, "Value"); 178 | assert_eq!(field_line_result.spans[1].content, "Value"); 179 | assert_eq!(highlighted_line_result.spans[1].content, "Value"); 180 | } 181 | 182 | #[test] 183 | fn test_helper_functions_with_unicode() { 184 | // Test with Unicode characters to ensure proper handling 185 | let unicode_cases = vec![ 186 | ("状态", "在线", Color::Green), 187 | ("Ñame", "Tëst", Color::Blue), 188 | ("🔧 Tool", "⚡ Status", Color::Yellow), 189 | ("Émoji", "🎉 Success", Color::Green), 190 | ]; 191 | 192 | for (label, value, color) in unicode_cases { 193 | let status_line_result = status_line(label, value, color); 194 | let field_line_result = field_line(label, value); 195 | let highlighted_line_result = highlighted_field_line(label, value, color); 196 | 197 | // Should handle Unicode without issues 198 | assert_eq!(status_line_result.spans[0].content, format!("{label}: ")); 199 | assert_eq!(status_line_result.spans[1].content, value); 200 | 201 | assert_eq!(field_line_result.spans[0].content, format!("{label}: ")); 202 | assert_eq!(field_line_result.spans[1].content, value); 203 | 204 | assert_eq!( 205 | highlighted_line_result.spans[0].content, 206 | format!("{label}: ") 207 | ); 208 | assert_eq!(highlighted_line_result.spans[1].content, value); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /tests/test_ui_layout.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::ui::layout::{centered_rect, create_main_layout}; 2 | use ratatui::layout::Rect; 3 | 4 | mod test_broker_utils; 5 | 6 | #[test] 7 | fn test_create_main_layout() { 8 | let area = Rect::new(0, 0, 100, 50); 9 | let layout = create_main_layout(area); 10 | 11 | assert_eq!(layout.len(), 3); 12 | 13 | // Header should be 3 units high 14 | assert_eq!(layout[0].height, 3); 15 | assert_eq!(layout[0].x, 0); 16 | assert_eq!(layout[0].y, 0); 17 | assert_eq!(layout[0].width, 100); 18 | 19 | // Status bar should be 3 units high at bottom 20 | assert_eq!(layout[2].height, 3); 21 | assert_eq!(layout[2].x, 0); 22 | assert_eq!(layout[2].y, 47); // 50 - 3 23 | assert_eq!(layout[2].width, 100); 24 | 25 | // Main content should fill remaining space 26 | assert_eq!(layout[1].height, 44); // 50 - 3 - 3 27 | assert_eq!(layout[1].x, 0); 28 | assert_eq!(layout[1].y, 3); 29 | assert_eq!(layout[1].width, 100); 30 | } 31 | 32 | #[test] 33 | fn test_create_main_layout_small_area() { 34 | let area = Rect::new(10, 5, 20, 10); 35 | let layout = create_main_layout(area); 36 | 37 | assert_eq!(layout.len(), 3); 38 | 39 | // Header 40 | assert_eq!(layout[0].height, 3); 41 | assert_eq!(layout[0].x, 10); 42 | assert_eq!(layout[0].y, 5); 43 | assert_eq!(layout[0].width, 20); 44 | 45 | // Status bar 46 | assert_eq!(layout[2].height, 3); 47 | assert_eq!(layout[2].x, 10); 48 | assert_eq!(layout[2].y, 12); // 5 + 10 - 3 49 | assert_eq!(layout[2].width, 20); 50 | 51 | // Main content (minimum height 0 due to constraint) 52 | assert_eq!(layout[1].height, 4); // 10 - 3 - 3 53 | assert_eq!(layout[1].x, 10); 54 | assert_eq!(layout[1].y, 8); // 5 + 3 55 | assert_eq!(layout[1].width, 20); 56 | } 57 | 58 | #[test] 59 | fn test_centered_rect_50_percent() { 60 | let area = Rect::new(0, 0, 100, 50); 61 | let centered = centered_rect(50, 50, area); 62 | 63 | // Should be 50% of width and height, centered 64 | assert_eq!(centered.width, 50); 65 | assert_eq!(centered.height, 25); 66 | assert_eq!(centered.x, 25); // (100 - 50) / 2 67 | assert_eq!(centered.y, 13); // Actual ratatui layout calculation 68 | } 69 | 70 | #[test] 71 | fn test_centered_rect_80_percent() { 72 | let area = Rect::new(0, 0, 100, 50); 73 | let centered = centered_rect(80, 70, area); 74 | 75 | // Should be 80% width, 70% height, centered 76 | assert_eq!(centered.width, 80); 77 | assert_eq!(centered.height, 35); 78 | assert_eq!(centered.x, 10); // (100 - 80) / 2 79 | assert_eq!(centered.y, 8); // Actual ratatui layout calculation 80 | } 81 | 82 | #[test] 83 | fn test_centered_rect_with_offset() { 84 | let area = Rect::new(20, 10, 60, 30); 85 | let centered = centered_rect(50, 50, area); 86 | 87 | // Should respect the area's offset 88 | assert_eq!(centered.width, 30); // 50% of 60 89 | assert_eq!(centered.height, 15); // 50% of 30 90 | assert_eq!(centered.x, 35); // 20 + (60 - 30) / 2 91 | assert_eq!(centered.y, 18); // Actual ratatui layout calculation 92 | } 93 | 94 | // Note: get_key_hints is a private function in layout.rs 95 | // Testing it indirectly through integration tests would be more appropriate 96 | // Since it's mainly used in draw_status_bar function 97 | -------------------------------------------------------------------------------- /tests/test_ui_modals.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::app::App; 2 | use lazycelery::models::{Task, TaskStatus}; 3 | use lazycelery::ui::modals::{draw_confirmation_dialog, draw_help, draw_task_details_modal}; 4 | use ratatui::backend::TestBackend; 5 | use ratatui::Terminal; 6 | 7 | mod test_broker_utils; 8 | use test_broker_utils::MockBrokerBuilder; 9 | 10 | #[test] 11 | fn test_modal_content_generation() { 12 | let broker = MockBrokerBuilder::empty().build(); 13 | let mut app = App::new(broker); 14 | 15 | // Test confirmation dialog setup 16 | app.show_confirmation = true; 17 | app.confirmation_message = "Are you sure you want to purge this queue?".to_string(); 18 | assert!(app.show_confirmation); 19 | assert!(!app.confirmation_message.is_empty()); 20 | 21 | // Test task details setup 22 | let test_task = Task { 23 | id: "test-task-123".to_string(), 24 | name: "test.task".to_string(), 25 | status: TaskStatus::Success, 26 | worker: Some("worker@host".to_string()), 27 | timestamp: chrono::Utc::now(), 28 | args: "[\"arg1\", \"arg2\"]".to_string(), 29 | kwargs: "{\"key\": \"value\"}".to_string(), 30 | result: Some("Task completed successfully".to_string()), 31 | traceback: None, 32 | }; 33 | 34 | app.selected_task_details = Some(test_task.clone()); 35 | app.show_task_details = true; 36 | assert!(app.show_task_details); 37 | assert!(app.selected_task_details.is_some()); 38 | 39 | if let Some(task) = &app.selected_task_details { 40 | assert_eq!(task.id, "test-task-123"); 41 | assert_eq!(task.name, "test.task"); 42 | assert_eq!(task.status, TaskStatus::Success); 43 | } 44 | } 45 | 46 | #[test] 47 | fn test_modal_state_transitions() { 48 | let broker = MockBrokerBuilder::empty().build(); 49 | let mut app = App::new(broker); 50 | 51 | // Test initial state 52 | assert!(!app.show_help); 53 | assert!(!app.show_confirmation); 54 | assert!(!app.show_task_details); 55 | assert!(app.selected_task_details.is_none()); 56 | 57 | // Test help modal 58 | app.show_help = true; 59 | assert!(app.show_help); 60 | 61 | // Test confirmation modal 62 | app.show_help = false; 63 | app.show_confirmation = true; 64 | app.confirmation_message = "Test confirmation".to_string(); 65 | assert!(!app.show_help); 66 | assert!(app.show_confirmation); 67 | 68 | // Test task details modal 69 | app.show_confirmation = false; 70 | app.show_task_details = true; 71 | let task = Task { 72 | id: "task-456".to_string(), 73 | name: "another.task".to_string(), 74 | status: TaskStatus::Failure, 75 | worker: Some("worker2@host".to_string()), 76 | timestamp: chrono::Utc::now(), 77 | args: "[]".to_string(), 78 | kwargs: "{}".to_string(), 79 | result: None, 80 | traceback: Some( 81 | "Traceback (most recent call last):\n File \"test.py\", line 1\nError: Test error" 82 | .to_string(), 83 | ), 84 | }; 85 | app.selected_task_details = Some(task); 86 | 87 | assert!(!app.show_confirmation); 88 | assert!(app.show_task_details); 89 | assert!(app.selected_task_details.is_some()); 90 | } 91 | 92 | #[test] 93 | fn test_task_details_with_failure_traceback() { 94 | let broker = MockBrokerBuilder::empty().build(); 95 | let mut app = App::new(broker); 96 | 97 | let failed_task = Task { 98 | id: "failed-task".to_string(), 99 | name: "failing.task".to_string(), 100 | status: TaskStatus::Failure, 101 | worker: Some("worker@host".to_string()), 102 | timestamp: chrono::Utc::now(), 103 | args: "[\"failed_arg\"]".to_string(), 104 | kwargs: "{\"debug\": true}".to_string(), 105 | result: None, 106 | traceback: Some("Traceback (most recent call last):\n File \"worker.py\", line 42, in execute\n raise ValueError(\"Test failure\")\nValueError: Test failure".to_string()), 107 | }; 108 | 109 | app.selected_task_details = Some(failed_task.clone()); 110 | app.show_task_details = true; 111 | 112 | if let Some(task) = &app.selected_task_details { 113 | assert_eq!(task.status, TaskStatus::Failure); 114 | assert!(task.traceback.is_some()); 115 | 116 | if let Some(traceback) = &task.traceback { 117 | assert!(traceback.contains("ValueError")); 118 | assert!(traceback.contains("Test failure")); 119 | assert!(traceback.lines().count() > 1); 120 | } 121 | } 122 | } 123 | 124 | #[test] 125 | fn test_task_details_various_statuses() { 126 | let broker = MockBrokerBuilder::empty().build(); 127 | let mut app = App::new(broker); 128 | 129 | let statuses = vec![ 130 | TaskStatus::Success, 131 | TaskStatus::Failure, 132 | TaskStatus::Pending, 133 | TaskStatus::Active, 134 | TaskStatus::Retry, 135 | TaskStatus::Revoked, 136 | ]; 137 | 138 | for status in statuses { 139 | let task = Task { 140 | id: format!("task-{status:?}").to_lowercase(), 141 | name: format!("test.{status:?}").to_lowercase(), 142 | status: status.clone(), 143 | worker: Some("test-worker".to_string()), 144 | timestamp: chrono::Utc::now(), 145 | args: "[]".to_string(), 146 | kwargs: "{}".to_string(), 147 | result: if status == TaskStatus::Success { 148 | Some("OK".to_string()) 149 | } else { 150 | None 151 | }, 152 | traceback: if status == TaskStatus::Failure { 153 | Some("Error occurred".to_string()) 154 | } else { 155 | None 156 | }, 157 | }; 158 | 159 | app.selected_task_details = Some(task.clone()); 160 | assert_eq!(app.selected_task_details.as_ref().unwrap().status, status); 161 | } 162 | } 163 | 164 | #[test] 165 | fn test_confirmation_dialog_messages() { 166 | let broker = MockBrokerBuilder::empty().build(); 167 | let mut app = App::new(broker); 168 | 169 | let test_messages = vec![ 170 | "Are you sure you want to purge the queue 'celery'?", 171 | "Confirm retry task 'test-task-123'?", 172 | "Revoke task 'failing-task-456'? This action cannot be undone.", 173 | "Delete all completed tasks?", 174 | ]; 175 | 176 | for message in test_messages { 177 | app.confirmation_message = message.to_string(); 178 | app.show_confirmation = true; 179 | 180 | assert_eq!(app.confirmation_message, message); 181 | assert!(app.show_confirmation); 182 | 183 | // Reset for next iteration 184 | app.show_confirmation = false; 185 | } 186 | } 187 | 188 | #[test] 189 | fn test_modal_priority_logic() { 190 | let broker = MockBrokerBuilder::empty().build(); 191 | let mut app = App::new(broker); 192 | 193 | // Multiple modals active - should follow priority order in UI rendering 194 | app.show_help = true; 195 | app.show_confirmation = true; 196 | app.show_task_details = true; 197 | 198 | // All can be true simultaneously in app state 199 | assert!(app.show_help); 200 | assert!(app.show_confirmation); 201 | assert!(app.show_task_details); 202 | 203 | // The actual priority is handled in the rendering functions 204 | // This test verifies the state can handle multiple modal flags 205 | } 206 | 207 | #[test] 208 | fn test_task_details_edge_cases() { 209 | let broker = MockBrokerBuilder::empty().build(); 210 | let mut app = App::new(broker); 211 | 212 | // Task with minimal data 213 | let minimal_task = Task { 214 | id: "minimal".to_string(), 215 | name: "minimal.task".to_string(), 216 | status: TaskStatus::Pending, 217 | worker: None, // No worker assigned 218 | timestamp: chrono::Utc::now(), 219 | args: "".to_string(), // Empty args 220 | kwargs: "".to_string(), // Empty kwargs 221 | result: None, 222 | traceback: None, 223 | }; 224 | 225 | app.selected_task_details = Some(minimal_task.clone()); 226 | 227 | if let Some(task) = &app.selected_task_details { 228 | assert!(task.worker.is_none()); 229 | assert!(task.args.is_empty()); 230 | assert!(task.kwargs.is_empty()); 231 | assert!(task.result.is_none()); 232 | assert!(task.traceback.is_none()); 233 | } 234 | 235 | // Task with very long data 236 | let long_task = Task { 237 | id: "x".repeat(100), 238 | name: "very.long.task.name.with.many.segments".to_string(), 239 | status: TaskStatus::Active, 240 | worker: Some("worker-with-very-long-hostname@example.domain.com".to_string()), 241 | timestamp: chrono::Utc::now(), 242 | args: format!("[{}]", "\"arg\", ".repeat(50)), 243 | kwargs: format!("{{{}}}", "\"key\": \"value\", ".repeat(20)), 244 | result: Some( 245 | "Very long result text that might wrap across multiple lines in the UI".to_string(), 246 | ), 247 | traceback: None, 248 | }; 249 | 250 | app.selected_task_details = Some(long_task.clone()); 251 | 252 | if let Some(task) = &app.selected_task_details { 253 | assert!(task.id.len() == 100); 254 | assert!(task.name.contains("very.long")); 255 | assert!(task.args.len() > 100); 256 | assert!(task.kwargs.len() > 100); 257 | } 258 | } 259 | 260 | // Integration test to verify modal rendering doesn't crash 261 | #[test] 262 | fn test_modal_rendering_integration() { 263 | let backend = TestBackend::new(80, 24); 264 | let mut terminal = Terminal::new(backend).unwrap(); 265 | 266 | let broker = MockBrokerBuilder::empty().build(); 267 | let mut app = App::new(broker); 268 | 269 | // Test rendering each modal type 270 | terminal 271 | .draw(|f| { 272 | app.show_help = true; 273 | draw_help(f); 274 | }) 275 | .unwrap(); 276 | 277 | app.show_help = false; 278 | app.show_confirmation = true; 279 | app.confirmation_message = "Test confirmation".to_string(); 280 | 281 | terminal 282 | .draw(|f| { 283 | draw_confirmation_dialog(f, &app); 284 | }) 285 | .unwrap(); 286 | 287 | app.show_confirmation = false; 288 | app.show_task_details = true; 289 | app.selected_task_details = Some(Task { 290 | id: "test".to_string(), 291 | name: "test.task".to_string(), 292 | status: TaskStatus::Success, 293 | worker: Some("worker".to_string()), 294 | timestamp: chrono::Utc::now(), 295 | args: "[]".to_string(), 296 | kwargs: "{}".to_string(), 297 | result: Some("OK".to_string()), 298 | traceback: None, 299 | }); 300 | 301 | terminal 302 | .draw(|f| { 303 | draw_task_details_modal(f, &app); 304 | }) 305 | .unwrap(); 306 | } 307 | -------------------------------------------------------------------------------- /tests/test_ui_widgets.rs: -------------------------------------------------------------------------------- 1 | use lazycelery::models::{TaskStatus, Worker, WorkerStatus}; 2 | use ratatui::style::Color; 3 | 4 | // Test for business logic without UI rendering 5 | mod widget_logic_tests { 6 | use super::*; 7 | 8 | #[test] 9 | fn test_task_status_color_mapping() { 10 | let test_cases = vec![ 11 | (TaskStatus::Success, Color::Green), 12 | (TaskStatus::Failure, Color::Red), 13 | (TaskStatus::Active, Color::Yellow), 14 | (TaskStatus::Pending, Color::Gray), 15 | (TaskStatus::Retry, Color::Magenta), 16 | (TaskStatus::Revoked, Color::DarkGray), 17 | ]; 18 | 19 | for (status, expected_color) in test_cases { 20 | let actual_color = match status { 21 | TaskStatus::Success => Color::Green, 22 | TaskStatus::Failure => Color::Red, 23 | TaskStatus::Active => Color::Yellow, 24 | TaskStatus::Pending => Color::Gray, 25 | TaskStatus::Retry => Color::Magenta, 26 | TaskStatus::Revoked => Color::DarkGray, 27 | }; 28 | assert_eq!( 29 | actual_color, expected_color, 30 | "Color mismatch for status: {status:?}" 31 | ); 32 | } 33 | } 34 | 35 | #[test] 36 | fn test_worker_status_symbols() { 37 | let online_symbol = match WorkerStatus::Online { 38 | WorkerStatus::Online => "●", 39 | WorkerStatus::Offline => "○", 40 | }; 41 | let offline_symbol = match WorkerStatus::Offline { 42 | WorkerStatus::Online => "●", 43 | WorkerStatus::Offline => "○", 44 | }; 45 | 46 | assert_eq!(online_symbol, "●"); 47 | assert_eq!(offline_symbol, "○"); 48 | } 49 | 50 | #[test] 51 | fn test_worker_utilization_calculation() { 52 | let worker = Worker { 53 | hostname: "test-worker".to_string(), 54 | status: WorkerStatus::Online, 55 | concurrency: 4, 56 | queues: vec![], 57 | active_tasks: vec!["task1".to_string(), "task2".to_string()], 58 | processed: 100, 59 | failed: 5, 60 | }; 61 | 62 | assert_eq!(worker.utilization(), 50.0); // 2/4 = 50% 63 | } 64 | 65 | #[test] 66 | fn test_task_viewport_logic() { 67 | let height = 10; 68 | let total_items = 20; 69 | 70 | // Test cases: (selected_index, expected_start) 71 | let test_cases: Vec<(usize, usize)> = vec![ 72 | (0, 0), // Beginning 73 | (5, 0), // Still within first page 74 | (10, 5), // Should center around selection 75 | (15, 10), // Should center around selection 76 | (19, 10), // Last item, start should be total - height 77 | ]; 78 | 79 | for (selected, expected_start) in test_cases { 80 | let start = if selected >= height && height > 0 { 81 | selected.saturating_sub(height / 2) 82 | } else { 83 | 0 84 | }; 85 | 86 | let end = (start + height).min(total_items); 87 | 88 | // Ensure we don't go beyond bounds 89 | let actual_start = if end == total_items && total_items > height { 90 | total_items - height 91 | } else { 92 | start 93 | }; 94 | 95 | assert_eq!( 96 | actual_start, expected_start, 97 | "Viewport calculation failed for selected={selected}, height={height}, total={total_items}" 98 | ); 99 | } 100 | } 101 | 102 | #[test] 103 | fn test_duration_formatting() { 104 | use chrono::Duration; 105 | 106 | let duration = Duration::hours(2) + Duration::minutes(30) + Duration::seconds(45); 107 | let formatted = format!( 108 | "{:02}:{:02}:{:02}", 109 | duration.num_hours(), 110 | duration.num_minutes() % 60, 111 | duration.num_seconds() % 60 112 | ); 113 | 114 | assert_eq!(formatted, "02:30:45"); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/test_utils.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Duration, TimeZone, Utc}; 2 | use lazycelery::utils::formatting::{format_duration, format_timestamp, truncate_string}; 3 | 4 | #[test] 5 | fn test_format_duration() { 6 | let duration = Duration::seconds(45); 7 | assert_eq!(format_duration(duration), "00:45"); 8 | 9 | let duration = Duration::minutes(5) + Duration::seconds(30); 10 | assert_eq!(format_duration(duration), "05:30"); 11 | 12 | let duration = Duration::hours(2) + Duration::minutes(15) + Duration::seconds(45); 13 | assert_eq!(format_duration(duration), "02:15:45"); 14 | 15 | let duration = Duration::hours(10) + Duration::minutes(5) + Duration::seconds(3); 16 | assert_eq!(format_duration(duration), "10:05:03"); 17 | } 18 | 19 | #[test] 20 | fn test_format_timestamp() { 21 | let timestamp = Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 45).unwrap(); 22 | assert_eq!(format_timestamp(timestamp), "2024-01-15 14:30:45"); 23 | 24 | let timestamp = Utc.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap(); 25 | assert_eq!(format_timestamp(timestamp), "2023-12-31 23:59:59"); 26 | } 27 | 28 | #[test] 29 | fn test_truncate_string() { 30 | assert_eq!(truncate_string("hello", 10), "hello"); 31 | assert_eq!(truncate_string("hello world", 8), "hello..."); 32 | assert_eq!( 33 | truncate_string("this is a very long string", 10), 34 | "this is..." 35 | ); 36 | assert_eq!(truncate_string("short", 5), "short"); 37 | assert_eq!(truncate_string("exactly", 7), "exactly"); 38 | 39 | // Edge case: max_len < 3 40 | assert_eq!(truncate_string("hello", 2), "..."); 41 | } 42 | -------------------------------------------------------------------------------- /tests/test_utils_formatting.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Datelike, Duration, TimeZone, Utc}; 2 | use lazycelery::utils::formatting::{format_duration, format_timestamp, truncate_string}; 3 | 4 | #[test] 5 | fn test_format_duration_seconds_only() { 6 | let duration = Duration::seconds(45); 7 | let formatted = format_duration(duration); 8 | assert_eq!(formatted, "00:45"); 9 | } 10 | 11 | #[test] 12 | fn test_format_duration_minutes_and_seconds() { 13 | let duration = Duration::seconds(125); // 2 minutes, 5 seconds 14 | let formatted = format_duration(duration); 15 | assert_eq!(formatted, "02:05"); 16 | } 17 | 18 | #[test] 19 | fn test_format_duration_with_hours() { 20 | let duration = Duration::seconds(3665); // 1 hour, 1 minute, 5 seconds 21 | let formatted = format_duration(duration); 22 | assert_eq!(formatted, "01:01:05"); 23 | } 24 | 25 | #[test] 26 | fn test_format_duration_zero() { 27 | let duration = Duration::seconds(0); 28 | let formatted = format_duration(duration); 29 | assert_eq!(formatted, "00:00"); 30 | } 31 | 32 | #[test] 33 | fn test_format_duration_exactly_one_hour() { 34 | let duration = Duration::seconds(3600); // Exactly 1 hour 35 | let formatted = format_duration(duration); 36 | assert_eq!(formatted, "01:00:00"); 37 | } 38 | 39 | #[test] 40 | fn test_format_duration_exactly_one_minute() { 41 | let duration = Duration::seconds(60); // Exactly 1 minute 42 | let formatted = format_duration(duration); 43 | assert_eq!(formatted, "01:00"); 44 | } 45 | 46 | #[test] 47 | fn test_format_duration_large_values() { 48 | let duration = Duration::seconds(359999); // 99 hours, 59 minutes, 59 seconds 49 | let formatted = format_duration(duration); 50 | assert_eq!(formatted, "99:59:59"); 51 | } 52 | 53 | #[test] 54 | fn test_format_timestamp() { 55 | let timestamp = Utc.with_ymd_and_hms(2023, 12, 25, 14, 30, 45).unwrap(); 56 | let formatted = format_timestamp(timestamp); 57 | assert_eq!(formatted, "2023-12-25 14:30:45"); 58 | } 59 | 60 | #[test] 61 | fn test_format_timestamp_start_of_year() { 62 | let timestamp = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 63 | let formatted = format_timestamp(timestamp); 64 | assert_eq!(formatted, "2024-01-01 00:00:00"); 65 | } 66 | 67 | #[test] 68 | fn test_format_timestamp_end_of_year() { 69 | let timestamp = Utc.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap(); 70 | let formatted = format_timestamp(timestamp); 71 | assert_eq!(formatted, "2023-12-31 23:59:59"); 72 | } 73 | 74 | #[test] 75 | fn test_format_timestamp_leap_year() { 76 | let timestamp = Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap(); 77 | let formatted = format_timestamp(timestamp); 78 | assert_eq!(formatted, "2024-02-29 12:00:00"); 79 | } 80 | 81 | #[test] 82 | fn test_truncate_string_no_truncation() { 83 | let result = truncate_string("hello", 10); 84 | assert_eq!(result, "hello"); 85 | } 86 | 87 | #[test] 88 | fn test_truncate_string_exact_length() { 89 | let result = truncate_string("hello", 5); 90 | assert_eq!(result, "hello"); 91 | } 92 | 93 | #[test] 94 | fn test_truncate_string_simple_truncation() { 95 | let result = truncate_string("hello world", 8); 96 | assert_eq!(result, "hello..."); 97 | } 98 | 99 | #[test] 100 | fn test_truncate_string_very_short_limit() { 101 | let result = truncate_string("hello", 3); 102 | assert_eq!(result, "..."); 103 | } 104 | 105 | #[test] 106 | fn test_truncate_string_shorter_than_ellipsis() { 107 | let result = truncate_string("hello", 2); 108 | assert_eq!(result, "..."); 109 | } 110 | 111 | #[test] 112 | fn test_truncate_string_empty_string() { 113 | let result = truncate_string("", 5); 114 | assert_eq!(result, ""); 115 | } 116 | 117 | #[test] 118 | fn test_truncate_string_zero_length() { 119 | let result = truncate_string("hello", 0); 120 | assert_eq!(result, "..."); 121 | } 122 | 123 | #[test] 124 | fn test_truncate_string_unicode() { 125 | // Note: This test might fail due to byte vs character counting 126 | // The current implementation uses byte indexing which can panic on unicode boundaries 127 | let result = truncate_string("héllo", 6); 128 | assert_eq!(result, "héllo"); 129 | } 130 | 131 | #[test] 132 | fn test_truncate_string_long_text() { 133 | let long_text = "The quick brown fox jumps over the lazy dog"; 134 | let result = truncate_string(long_text, 20); 135 | assert_eq!(result, "The quick brown f..."); 136 | } 137 | 138 | #[test] 139 | fn test_truncate_string_single_character() { 140 | let result = truncate_string("a", 1); 141 | assert_eq!(result, "a"); 142 | } 143 | 144 | #[test] 145 | fn test_format_duration_negative() { 146 | // Test behavior with negative durations 147 | let duration = Duration::seconds(-30); 148 | let formatted = format_duration(duration); 149 | // The behavior with negative durations depends on implementation 150 | // This test documents the current behavior 151 | assert!(formatted.contains("00")); 152 | } 153 | 154 | #[test] 155 | fn test_format_duration_boundary_values() { 156 | // Test various boundary values 157 | let test_cases = vec![ 158 | (59, "00:59"), // Just under 1 minute 159 | (60, "01:00"), // Exactly 1 minute 160 | (61, "01:01"), // Just over 1 minute 161 | (3599, "59:59"), // Just under 1 hour 162 | (3600, "01:00:00"), // Exactly 1 hour 163 | (3661, "01:01:01"), // Just over 1 hour 164 | ]; 165 | 166 | for (seconds, expected) in test_cases { 167 | let duration = Duration::seconds(seconds); 168 | let formatted = format_duration(duration); 169 | assert_eq!(formatted, expected, "Failed for {seconds} seconds"); 170 | } 171 | } 172 | 173 | #[test] 174 | fn test_truncate_string_edge_cases() { 175 | let test_cases = vec![ 176 | ("", 0, ""), // Empty string stays empty even with 0 max_len 177 | ("", 1, ""), 178 | ("", 10, ""), 179 | ("a", 0, "..."), // Non-empty string with 0 max_len gets "..." 180 | ("a", 1, "a"), 181 | ("ab", 1, "..."), 182 | ("abc", 3, "abc"), // max_len == string length, no truncation needed 183 | ("abcd", 4, "abcd"), // max_len == string length, no truncation needed 184 | ("abcd", 5, "abcd"), 185 | ]; 186 | 187 | for (input, max_len, expected) in test_cases { 188 | let result = truncate_string(input, max_len); 189 | assert_eq!( 190 | result, expected, 191 | "Failed for input '{input}' with max_len {max_len}" 192 | ); 193 | } 194 | } 195 | 196 | #[test] 197 | fn test_formatting_functions_with_realistic_data() { 198 | // Test with more realistic data that might be encountered in the application 199 | 200 | // Test long task names that need truncation 201 | let long_task_name = "my_app.tasks.process_large_dataset_with_complex_calculations"; 202 | let truncated = truncate_string(long_task_name, 30); 203 | assert_eq!(truncated, "my_app.tasks.process_large_..."); 204 | 205 | // Test typical durations for Celery tasks 206 | let quick_task = Duration::seconds(2); // 2 second task 207 | assert_eq!(format_duration(quick_task), "00:02"); 208 | 209 | let medium_task = Duration::seconds(450); // 7.5 minute task 210 | assert_eq!(format_duration(medium_task), "07:30"); 211 | 212 | let long_task = Duration::seconds(7200); // 2 hour task 213 | assert_eq!(format_duration(long_task), "02:00:00"); 214 | 215 | // Test recent timestamp 216 | let recent_time = Utc::now(); 217 | let formatted_time = format_timestamp(recent_time); 218 | // Should contain the current year and be in correct format 219 | assert!(formatted_time.len() == 19); // YYYY-MM-DD HH:MM:SS format 220 | assert!(formatted_time.contains(&recent_time.year().to_string())); 221 | } 222 | --------------------------------------------------------------------------------