diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 4893600..18c6da1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -9,6 +9,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends build-essential pkg-config libssl-dev USER vscode -RUN curl --proto '=https' --tlsv1.2 -sSf curl https://sh.rustup.rs | sh -s -- -y -# RUN rustup target add x86_64-unknown-linux-musl - +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d86c30d..28b0715 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -16,14 +16,11 @@ "seccomp=unconfined" ], - "postCreateCommand": "rustup target add x86_64-unknown-linux-musl && cargo install cross typos-cli", + "postCreateCommand": "rustup target add x86_64-unknown-linux-musl && cargo install cross typos-cli cargo-expand", "mounts": [ "source=/etc/timezone,target=/etc/timezone,type=bind,readonly" ], - "containerEnv": { - "CARGO_REGISTRIES_CRATES_IO_PROTOCOL": "sparse" - }, "customizations": { "vscode": { @@ -35,7 +32,6 @@ "mutantdino.resourcemonitor", "rangav.vscode-thunder-client", "redhat.vscode-yaml", - "redhat.vscode-yaml", "rust-lang.rust-analyzer", "serayuzgur.crates", "tamasfe.even-better-toml", diff --git a/.github/release-body.md b/.github/release-body.md index 26e3358..ff13266 100644 --- a/.github/release-body.md +++ b/.github/release-body.md @@ -1,20 +1,17 @@ -### 2023-10-21 +### 2023-11-20 ### Chores -+ docker-compose Alpine bump, [d46c425fa29f3c1d27bd57764748bae7e0b82f69] -+ dependencies updated, [e6eecbbdce9c0ccff42aa8806dddb6e3364f990c], [ec93115ece83002fa127f3358f573319e29357e1], [b36daa5aeaa354b6c4f45b7ae67ac1a6345ea1c0], [9c0de1f0feff3165d0f5b6cb5dda843c124bcfa4], [6dd953df458096aee5914411ce40e46c3f600ede] -+ Rust 1.73 linting, [21234c66c3935330ccd58543dd3a915a293ac776] ++ workflow dependencies updated, [6a4cf6490d08b976734e2bc8186d94c095700558] ++ dependencies updated, [e301b51891e03ea40b2f904583119da3bc4daf53], [81d5b326db8881263f2c9072e1426948e41b4a0f], [294cc2684f42daab9d51601e235a384f55617678] ++ lints moved from main.rs to Cargo.toml, [2de76e2f358be9c1500ca3dc4f9df0979ed8ed28] ++ .devcontainer updated, [37d2ee915625806dd11c2cc816a892aae12a777c] -### Docs -+ README.md updated, [3fd3915b3e929742d8007109fd4c7b4a345eb0fa] - -### Refactors -+ LogsTZ from `&str`, [44f581f5b3652cc4e623fe145141878754dca292] -+ from string impl, [ca79893df5f05ebf445ce194d578cb8213c9755e] -+ env handling, [18c3ed43376a8b5e2d285d1b34a9f96843357d53] -+ `parse_args/mod.rs` > `parse_args.rs`, [a6ff4124319ed17d3f1c46c916418f850ef1d3b0] -+ set_info_box take `&str`, [faeaca0cd1bb243c7f4a7112b928be776b877ca1] -+ GitHub action use concurrency matrix, re-roder workflow, [85f1982f4066bfdbc764ab7b88588eded6a17f96] +### Features ++ Docker exec mode - you are now able to attempt to exec into a container by pressing the `e` key, closes #28, [c8077bca0b673478cfbb417e677a885136ba9eff], [0e5ee143b008c9d0ee0b681231a1568be227150b], [0e5ee143b008c9d0ee0b681231a1568be227150b] ++ Export logs feature, press `s` to save logs, use `--save-dir` cli-arg to customise output location, closes #1, [a15da5ed43d07852504a4dd1884a189e3f5b9d84] +### Fixes ++ `as_ref()` fixed, thanks [Daniel-Boll](https://github.com/Daniel-Boll), [0e06c9c172629dc7f7e7766f5372da9466e786d8] ++ sorted created_at clash, closes #22, [3a6489396e87702ce94b349a7f47028ece7922f6] see CHANGELOG.md for more details diff --git a/.github/screenshot_01.png b/.github/screenshot_01.png index 06b5086..2b51951 100644 Binary files a/.github/screenshot_01.png and b/.github/screenshot_01.png differ diff --git a/.github/workflows/create_release_and_build.yml b/.github/workflows/create_release_and_build.yml index 81f7265..6acc896 100644 --- a/.github/workflows/create_release_and_build.yml +++ b/.github/workflows/create_release_and_build.yml @@ -77,7 +77,7 @@ jobs: uses: actions/download-artifact@v3 - name: Update Release - uses: ncipollo/release-action@v1.12.0 + uses: ncipollo/release-action@v1 with: makeLatest: true name: ${{ github.ref_name }} @@ -103,11 +103,11 @@ jobs: uses: katyo/publish-crates@v1 with: registry-token: ${{ secrets.CRATES_IO_TOKEN }} - + ######################################### ## Build images for Dockerhub & ghcr.io # ######################################### - + image_build: needs: [cargo_publish] runs-on: ubuntu-latest @@ -116,14 +116,14 @@ jobs: uses: actions/checkout@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -133,7 +133,7 @@ jobs: CURRENT_SEMVER=${GITHUB_REF_NAME#v} echo "CURRENT_SEMVER=$CURRENT_SEMVER" >> $GITHUB_ENV - - uses: docker/setup-buildx-action@v2 + - uses: docker/setup-buildx-action@v3 id: buildx with: install: true @@ -146,6 +146,4 @@ jobs: -t ghcr.io/${{ github.repository_owner }}/oxker:${{env.CURRENT_SEMVER}} \ --provenance=false --sbom=false \ --push \ - -f containerised/Dockerfile . - - + -f containerised/Dockerfile . \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1481fbc..e420888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# v0.4.0 +### 2023-11-20 + +### Chores ++ workflow dependencies updated, [6a4cf649](https://github.com/mrjackwills/oxker/commit/6a4cf6490d08b976734e2bc8186d94c095700558) ++ dependencies updated, [e301b518](https://github.com/mrjackwills/oxker/commit/e301b51891e03ea40b2f904583119da3bc4daf53), [81d5b326](https://github.com/mrjackwills/oxker/commit/81d5b326db8881263f2c9072e1426948e41b4a0f), [294cc268](https://github.com/mrjackwills/oxker/commit/294cc2684f42daab9d51601e235a384f55617678) ++ lints moved from main.rs to Cargo.toml, [2de76e2f](https://github.com/mrjackwills/oxker/commit/2de76e2f358be9c1500ca3dc4f9df0979ed8ed28) ++ .devcontainer updated, [37d2ee91](https://github.com/mrjackwills/oxker/commit/37d2ee915625806dd11c2cc816a892aae12a777c) + +### Features ++ Docker exec mode - you are now able to attempt to exec into a container by pressing the `e` key, closes [#28](https://github.com/mrjackwills/oxker/issues/28), [c8077bca](https://github.com/mrjackwills/oxker/commit/c8077bca0b673478cfbb417e677a885136ba9eff), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b) ++ Export logs feature, press `s` to save logs, use `--save-dir` cli-arg to customise output location, closes [#1](https://github.com/mrjackwills/oxker/issues/1), [a15da5ed](https://github.com/mrjackwills/oxker/commit/a15da5ed43d07852504a4dd1884a189e3f5b9d84) + +### Fixes ++ `as_ref()` fixed, thanks [Daniel-Boll](https://github.com/Daniel-Boll), [0e06c9c1](https://github.com/mrjackwills/oxker/commit/0e06c9c172629dc7f7e7766f5372da9466e786d8) ++ sorted created_at clash, closes [#22](https://github.com/mrjackwills/oxker/issues/22), [3a648939](https://github.com/mrjackwills/oxker/commit/3a6489396e87702ce94b349a7f47028ece7922f6) + # v0.3.3 ### 2023-10-21 diff --git a/Cargo.lock b/Cargo.lock index f4731fc..aa369d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -109,9 +127,9 @@ dependencies = [ [[package]] name = "base64" -version = "0.21.4" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" [[package]] name = "bitflags" @@ -128,8 +146,7 @@ checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" [[package]] name = "bollard" version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03db470b3c0213c47e978da93200259a1eb4dae2e5512cba9955e2b540a6fc6" +source = "git+https://www.github.com/fussybeaver/bollard.git?rev=cef1cd5#cef1cd568684d0c3c497ce56221ff22ca18334f4" dependencies = [ "base64", "bollard-stubs", @@ -157,8 +174,7 @@ dependencies = [ [[package]] name = "bollard-stubs" version = "1.43.0-rc.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58071e8fd9ec1e930efd28e3a90c1251015872a2ce49f81f36421b86466932e" +source = "git+https://www.github.com/fussybeaver/bollard.git?rev=cef1cd5#cef1cd568684d0c3c497ce56221ff22ca18334f4" dependencies = [ "serde", "serde_repr", @@ -219,9 +235,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.6" +version = "4.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d04704f56c2cde07f43e8e2c154b43f216dc5c92fc98ada720177362f953b956" +checksum = "2275f18819641850fa26c89acc84d465c1bf91ce57bc2748b28c420473352f64" dependencies = [ "clap_builder", "clap_derive", @@ -229,9 +245,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.6" +version = "4.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e231faeaca65ebd1ea3c737966bf858971cd38c3849107aa3ea7de90a804e45" +checksum = "07cdf1b148b25c1e1f7a42225e30a0d99a615cd4637eae7365548dd4529b95bc" dependencies = [ "anstream", "anstyle", @@ -243,9 +259,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.4.2" +version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ "heck", "proc-macro2", @@ -255,9 +271,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "colorchoice" @@ -306,6 +322,27 @@ dependencies = [ "serde", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "either" version = "1.9.0" @@ -335,24 +372,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" [[package]] name = "futures-macro" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", @@ -361,21 +398,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ "futures-core", "futures-macro", @@ -387,9 +424,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ "cfg-if", "libc", @@ -404,9 +441,9 @@ checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" [[package]] name = "h2" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" dependencies = [ "bytes", "fnv", @@ -414,7 +451,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 1.9.3", + "indexmap 2.1.0", "slab", "tokio", "tokio-util", @@ -432,6 +469,10 @@ name = "hashbrown" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -453,9 +494,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "0.2.9" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" dependencies = [ "bytes", "fnv", @@ -568,9 +609,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown 0.14.2", @@ -600,9 +641,9 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "js-sys" -version = "0.3.64" +version = "0.3.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" dependencies = [ "wasm-bindgen", ] @@ -615,9 +656,20 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.149" +version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" + +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] [[package]] name = "lock_api" @@ -635,6 +687,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efa59af2ddfad1854ae27d75009d538d0998b4b2fd47083e743ac1a10e46c60" +dependencies = [ + "hashbrown 0.14.2", +] + [[package]] name = "memchr" version = "2.6.4" @@ -652,9 +713,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ "libc", "log", @@ -706,6 +767,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -714,13 +781,14 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "oxker" -version = "0.3.3" +version = "0.4.0" dependencies = [ "anyhow", "bollard", "cansi", "clap", "crossterm", + "directories", "futures-util", "parking_lot", "ratatui", @@ -859,15 +927,16 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad" +checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" dependencies = [ "bitflags 2.4.1", "cassowary", "crossterm", "indoc", "itertools", + "lru", "paste", "strum", "unicode-segmentation", @@ -883,6 +952,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -909,18 +989,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.189" +version = "1.0.192" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" +checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.189" +version = "1.0.192" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" +checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", @@ -929,9 +1009,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.107" +version = "1.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" dependencies = [ "itoa", "ryu", @@ -940,9 +1020,9 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" dependencies = [ "proc-macro2", "quote", @@ -971,7 +1051,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.0.2", + "indexmap 2.1.0", "serde", "serde_json", "time", @@ -1027,9 +1107,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "socket2" @@ -1081,9 +1161,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.38" +version = "2.0.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" +checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" dependencies = [ "proc-macro2", "quote", @@ -1166,9 +1246,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.33.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ "backtrace", "bytes", @@ -1185,9 +1265,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", @@ -1196,9 +1276,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" dependencies = [ "bytes", "futures-core", @@ -1248,20 +1328,20 @@ dependencies = [ [[package]] name = "tracing-log" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "lazy_static", "log", + "once_cell", "tracing-core", ] [[package]] name = "tracing-subscriber" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -1338,9 +1418,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" +checksum = "c58fe91d841bc04822c9801002db4ea904b9e4b8e6bbad25127b46eff8dc516b" dependencies = [ "getrandom", "rand", @@ -1375,9 +1455,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1385,9 +1465,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" dependencies = [ "bumpalo", "log", @@ -1400,9 +1480,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1410,9 +1490,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" dependencies = [ "proc-macro2", "quote", @@ -1423,9 +1503,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" [[package]] name = "winapi" @@ -1523,3 +1603,23 @@ name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zerocopy" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index e0055f6..9ce8d4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxker" -version = "0.3.3" +version = "0.4.0" edition = "2021" authors = ["Jack Wills "] description = "A simple tui to view & control docker containers" @@ -11,19 +11,35 @@ readme = "README.md" keywords = ["docker", "tui", "tokio", "terminal", "podman"] categories = ["command-line-utilities"] +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +expect_used = "warn" +nursery = "warn" +pedantic ="warn" +todo = "warn" +unused_async = "warn" +unwrap_used = "warn" +module_name_repetitions = "allow" +doc_markdown = "allow" +similar_names = "allow" + [dependencies] anyhow = "1.0" -bollard = "0.15" +# bollard = "0.15" +bollard = { git = "https://www.github.com/fussybeaver/bollard.git", rev = "cef1cd5" } cansi = "2.2" clap = { version = "4.4", features = ["derive", "unicode", "color"] } crossterm = "0.27" futures-util = "0.3" parking_lot = { version= "0.12" } -tokio = { version = "1.33", features = ["full"] } +tokio = { version = "1.34", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" -ratatui = "0.23" -uuid = { version = "1.5", features = ["v4", "fast-rng"] } +ratatui = "0.24" +uuid = { version = "1.6", features = ["v4", "fast-rng"] } +directories = "5.0" [dev-dependencies] diff --git a/README.md b/README.md index 6c0140e..f093869 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@

+- [Download & install](#download--install) +- [Run](#run) +- [Build step](#build-step) +- [Tests](#tests) ## Download & install @@ -92,26 +96,30 @@ oxker In application controls | button| result| |--|--| -| ```( tab )``` or ```( shift+tab )``` | change panel, clicking on a panel also changes the selected panel| -| ```( ↑ ↓ )``` or ```( j k )``` or ```( PgUp PgDown )``` or ```( Home End )```| change selected line in selected panel, mouse scroll also changes selected line | -| ```( enter )```| execute selected docker command| -| ```( 1-9 )``` | sort containers by heading, clicking on headings also sorts the selected column | -| ```( 0 )``` | stop sorting | -| ```( h )``` | toggle help menu | -| ```( m )``` | toggle mouse capture - if disabled, text on screen can be selected| -| ```( q )``` | to quit at any time | +| ```( tab )``` or ```( shift+tab )``` | Change panel, clicking on a panel also changes the selected panel.| +| ```( ↑ ↓ )``` or ```( j k )``` or ```( PgUp PgDown )``` or ```( Home End )```| Change selected line in selected panel, mouse scroll also changes selected line.| +| ```( enter )```| Run selected docker command.| +| ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. | +| ```( 0 )``` | Stop sorting.| +| ```( e )``` | Exec into the selected container.| +| ```( h )``` | Toggle help menu.| +| ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.| +| ```( q )``` | Quit.| +| ```( s )``` | Save logs to `$HOME/[container_name]_[timestamp].log`, or the directory set by `--save-dir`.| Available command line arguments | argument|result| |--|--| -|```-d [number > 0]```| set the minimum update interval for docker information, in ms, defaults to 1000 (1 second) | -|```--host [hostname]```| connect to Docker with a custom hostname, defaults to `/var/run/docker.sock`, will use `$DOCKER_HOST` env if set | -|```-r```| show raw logs, by default oxker will remove ANSI formatting (conflicts with -c) | -|```-c```| attempt to color the logs (conflicts with -r) | -|```-t```| remove timestamps from each log entry | -|```-s```| if running via docker, will show the oxker container | -|```-g```| no tui, basically a pointless debugging mode, for now | +|```-d [number > 0]```| Set the minimum update interval for docker information in milliseconds. Defaults to 1000 (1 second).| +|```-r```| Show raw logs. By default, removes ANSI formatting (conflicts with `-c`).| +|```-c```| Attempt to color the logs (conflicts with `-r`).| +|```-t```| Remove timestamps from each log entry.| +|```-s```| If running via Docker, will display the oxker container.| +|```-g```| No TUI, essentially a debugging mode with limited functionality, for now.| +|```--host [hostname]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.| +|```--save-dir [directory]```| Save exported logs into a custom directory. Defaults to `$HOME`.| +|```--use-cli```| Use the Docker application when exec-ing into a container, instead of the Docker API.| ## Build step diff --git a/containerised/Dockerfile b/containerised/Dockerfile index d3f255d..7c12c96 100644 --- a/containerised/Dockerfile +++ b/containerised/Dockerfile @@ -49,14 +49,13 @@ RUN cp /usr/src/oxker/target/$(cat /.platform)/release/oxker / ## Runtime ## ############# -FROM scratch AS RUNTIME +FROM scratch as RUNTIME -# Set an ENV that we're running in a container, so that the application can sleep for 250ms at start +# Set an ENV to indicate that we're running in a container ENV OXKER_RUNTIME=container -# Copy application binary from builder image COPY --from=BUILDER /oxker /app/ # Run the application -# this is used in the application itself, to stop itself from listing itself, so DO NOT EDIT -ENTRYPOINT [ "/app/oxker"] +# this is used in the application itself so DO NOT EDIT +ENTRYPOINT [ "/app/oxker"] \ No newline at end of file diff --git a/containerised/Dockerfile_dev b/containerised/Dockerfile_dev index 6ff45ba..8a47e04 100644 --- a/containerised/Dockerfile_dev +++ b/containerised/Dockerfile_dev @@ -28,3 +28,23 @@ ENTRYPOINT [ "/app/oxker"] # Buildx command to build musl version for all three platforms, should probably be executed in create_release # docker buildx create --use # docker buildx build --platform linux/arm/v6,linux/arm64,linux/amd64 -t oxker_dev_all -o type=tar,dest=/tmp/oxker_dev_all.tar -f containerised/Dockerfile . + + +# Build production version for x86 only, then run +# docker build --platform linux/amd64 -t oxker_dev -f containerised/Dockerfile . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev + +# docker build --platform linux/arm/v6 -t oxker_dev -f containerised/Dockerfile . + +### Build docker files and save to .tar file + +# docker build --platform linux/amd64 -t oxker_dev_amd64 -f containerised/Dockerfile .; docker save -o ./oxker_dev_amd64.tar oxker_dev_amd64 +# docker load -i oxker_dev_amd64.tar +# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev_amd64 + +# docker build --platform linux/arm64 -t oxker_dev_arm64 -f containerised/Dockerfile .; docker save -o ./oxker_dev_arm64.tar oxker_dev_arm64 +# docker load -i oxker_dev_arm64.tar +# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev oxker_dev_arm64 + +# docker build --platform linux/arm/v6 -t oxker_dev_armv6 -f containerised/Dockerfile .; docker save -o ./oxker_dev_armv6.tar oxker_dev_armv6 +# docker load -i oxker_dev_armv6.tar +# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev_armv6 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cbf627b..0521dea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: deploy: resources: limits: - memory: 128M + memory: 1024M redis: image: redis:alpine3.18 container_name: redis @@ -27,7 +27,7 @@ services: deploy: resources: limits: - memory: 16M + memory: 384M rabbitmq: image: rabbitmq:3 container_name: rabbitmq @@ -38,6 +38,6 @@ services: deploy: resources: limits: - memory: 256M + memory: 512M diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index 08f0785..8c4973f 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -28,6 +28,11 @@ impl ContainerId { pub fn get(&self) -> &str { self.0.as_str() } + + /// Only return first 8 chars of id, is usually more than enough for uniqueness + pub fn get_short(&self) -> String { + self.0.chars().take(8).collect::() + } } impl Ord for ContainerId { @@ -121,6 +126,9 @@ pub enum State { } impl State { + pub const fn is_alive(self) -> bool { + matches!(self, Self::Running) + } pub const fn get_color(self) -> Color { match self { Self::Paused => Color::Yellow, @@ -158,6 +166,12 @@ impl From<&str> for State { } } +impl From> for State { + fn from(input: Option) -> Self { + input.map_or(Self::Unknown, |input| Self::from(input.as_str())) + } +} + impl fmt::Display for State { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let disp = match self { @@ -216,7 +230,7 @@ impl fmt::Display for DockerControls { Self::Restart => "restart", Self::Start => "start", Self::Stop => "stop", - Self::Unpause => "unpause", + Self::Unpause => "resume", }; write!(f, "{disp}") } @@ -434,6 +448,20 @@ pub struct ContainerItem { pub is_oxker: bool, } +/// Basic display information, for when running in debug mode +impl fmt::Display for ContainerItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}, {}, {}, {}", + self.id.get_short(), + self.name, + self.cpu_stats.back().unwrap_or(&CpuStats::new(0.0)), + self.mem_stats.back().unwrap_or(&ByteStats::new(0)) + ) + } +} + impl ContainerItem { /// Create a new container item pub fn new( diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index 6a851a9..745572f 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -17,6 +17,7 @@ use crate::{ }; pub use container_state::*; +#[cfg(not(debug_assertions))] /// Global app_state, stored in an Arc #[derive(Debug, Clone)] pub struct AppData { @@ -26,6 +27,17 @@ pub struct AppData { pub args: CliArgs, } +#[cfg(debug_assertions)] +/// Global app_state, stored in an Arc +#[derive(Debug, Clone)] +pub struct AppData { + containers: StatefulList, + error: Option, + sorted_by: Option<(Header, SortedOrder)>, + debug_string: String, + pub args: CliArgs, +} + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum SortedOrder { Asc, @@ -64,6 +76,17 @@ impl fmt::Display for Header { } impl AppData { + #[cfg(debug_assertions)] + pub fn get_debug_string(&self) -> &str { + &self.debug_string + } + + #[cfg(debug_assertions)] + #[allow(unused)] + pub fn push_debug_string(&mut self, x: &str) { + self.debug_string.push_str(x); + } + /// Change the sorted order, also set the selected container state to match new order fn set_sorted(&mut self, x: Option<(Header, SortedOrder)>) { self.sorted_by = x; @@ -86,6 +109,7 @@ impl AppData { } /// Generate a default app_state + #[cfg(not(debug_assertions))] pub fn default(args: CliArgs) -> Self { Self { args, @@ -95,6 +119,18 @@ impl AppData { } } + /// Generate a default app_state + #[cfg(debug_assertions)] + pub fn default(args: CliArgs) -> Self { + Self { + args, + containers: StatefulList::new(vec![]), + error: None, + sorted_by: None, + debug_string: String::new(), + } + } + /// Container sort related methods /// Remove the sorted header & order, and sort by default - created datetime @@ -120,78 +156,85 @@ impl AppData { self.sorted_by } - /// Sort the containers vec, based on a heading, either ascending or descending, + /// Sort the containers vec, based on a heading (and if clash, then by name), either ascending or descending, /// If not sort set, then sort by created time pub fn sort_containers(&mut self) { if let Some((head, ord)) = self.sorted_by { - match head { - Header::State => match ord { - SortedOrder::Asc => self - .containers - .items - .sort_by(|a, b| b.state.order().cmp(&a.state.order())), - SortedOrder::Desc => self - .containers - .items - .sort_by(|a, b| a.state.order().cmp(&b.state.order())), - }, - Header::Status => match ord { - SortedOrder::Asc => self - .containers - .items - .sort_by(|a, b| a.status.cmp(&b.status)), - SortedOrder::Desc => self - .containers - .items - .sort_by(|a, b| b.status.cmp(&a.status)), - }, - Header::Cpu => match ord { - SortedOrder::Asc => self - .containers - .items - .sort_by(|a, b| a.cpu_stats.back().cmp(&b.cpu_stats.back())), - SortedOrder::Desc => self - .containers - .items - .sort_by(|a, b| b.cpu_stats.back().cmp(&a.cpu_stats.back())), - }, - Header::Memory => match ord { - SortedOrder::Asc => self - .containers - .items - .sort_by(|a, b| a.mem_stats.back().cmp(&b.mem_stats.back())), - SortedOrder::Desc => self - .containers - .items - .sort_by(|a, b| b.mem_stats.back().cmp(&a.mem_stats.back())), - }, - Header::Id => match ord { - SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.id.cmp(&b.id)), - SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.id.cmp(&a.id)), - }, - Header::Image => match ord { - SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.image.cmp(&b.image)), - SortedOrder::Desc => { - self.containers.items.sort_by(|a, b| b.image.cmp(&a.image)); - } - }, - Header::Name => match ord { - SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.name.cmp(&b.name)), - SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.name.cmp(&a.name)), - }, - Header::Rx => match ord { - SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.rx.cmp(&b.rx)), - SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.rx.cmp(&a.rx)), - }, - Header::Tx => match ord { - SortedOrder::Asc => self.containers.items.sort_by(|a, b| a.tx.cmp(&b.tx)), - SortedOrder::Desc => self.containers.items.sort_by(|a, b| b.tx.cmp(&a.tx)), - }, - } + let sort_closure = |a: &ContainerItem, b: &ContainerItem| -> std::cmp::Ordering { + match head { + Header::State => match ord { + SortedOrder::Asc => a + .state + .order() + .cmp(&b.state.order()) + .then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b + .state + .order() + .cmp(&a.state.order()) + .then_with(|| b.name.cmp(&a.name)), + }, + Header::Status => match ord { + SortedOrder::Asc => { + a.status.cmp(&b.status).then_with(|| a.name.cmp(&b.name)) + } + SortedOrder::Desc => { + b.status.cmp(&a.status).then_with(|| b.name.cmp(&a.name)) + } + }, + Header::Cpu => match ord { + SortedOrder::Asc => a + .cpu_stats + .back() + .cmp(&b.cpu_stats.back()) + .then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b + .cpu_stats + .back() + .cmp(&a.cpu_stats.back()) + .then_with(|| b.name.cmp(&a.name)), + }, + Header::Memory => match ord { + SortedOrder::Asc => a + .mem_stats + .back() + .cmp(&b.mem_stats.back()) + .then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b + .mem_stats + .back() + .cmp(&a.mem_stats.back()) + .then_with(|| b.name.cmp(&a.name)), + }, + Header::Id => match ord { + SortedOrder::Asc => a.id.cmp(&b.id).then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b.id.cmp(&a.id).then_with(|| b.name.cmp(&a.name)), + }, + Header::Image => match ord { + SortedOrder::Asc => a.image.cmp(&b.image).then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => { + b.image.cmp(&a.image).then_with(|| b.name.cmp(&a.name)) + } + }, + Header::Name => match ord { + SortedOrder::Asc => a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)), + SortedOrder::Desc => b.name.cmp(&a.name).then_with(|| b.id.cmp(&a.id)), + }, + Header::Rx => match ord { + SortedOrder::Asc => a.rx.cmp(&b.rx).then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b.rx.cmp(&a.rx).then_with(|| b.name.cmp(&a.name)), + }, + Header::Tx => match ord { + SortedOrder::Asc => a.tx.cmp(&b.tx).then_with(|| a.name.cmp(&b.name)), + SortedOrder::Desc => b.tx.cmp(&a.tx).then_with(|| b.name.cmp(&a.name)), + }, + } + }; + self.containers.items.sort_by(sort_closure); } else { self.containers .items - .sort_by(|a, b| a.created.cmp(&b.created)); + .sort_by(|a, b| a.created.cmp(&b.created).then_with(|| a.name.cmp(&b.name))); } } @@ -410,18 +453,6 @@ impl AppData { self.get_selected_container().map_or(false, |i| i.is_oxker) } - /// Check if the initial parsing has been completed, by making sure that all ids given (which are running) have a non empty cpu_stats vecdec - pub fn initialised(&mut self, all_ids: &[(bool, ContainerId)]) -> bool { - let count_is_running = all_ids.iter().filter(|i| i.0).count(); - let number_with_cpu_status = self - .containers - .items - .iter() - .filter(|i| !i.cpu_stats.is_empty()) - .count(); - count_is_running == number_with_cpu_status - } - /// Find the widths for the strings in the containers panel. /// So can display nicely and evenly pub fn get_width(&self) -> Columns { @@ -482,6 +513,12 @@ impl AppData { self.get_selected_container().map(|i| i.id.clone()) } + /// Get the Id and State for the currently selected container - used by the exec check method + pub fn get_selected_container_id_state_name(&self) -> Option<(ContainerId, State, String)> { + self.get_selected_container() + .map(|i| (i.id.clone(), i.state, i.name.clone())) + } + /// Update container mem, cpu, & network stats, in single function so only need to call .lock() once /// Will also, if a sort is set, sort the containers pub fn update_stats( @@ -563,6 +600,8 @@ impl AppData { }) }); + let id = ContainerId::from(id.as_str()); + let is_oxker = i .command .as_ref() @@ -579,8 +618,6 @@ impl AppData { .as_ref() .map_or(String::new(), std::clone::Clone::clone); - let id = ContainerId::from(id.as_str()); - let created = i .created .map_or(0, |i| u64::try_from(i).unwrap_or_default()); @@ -624,31 +661,33 @@ impl AppData { let timestamp = self.args.timestamp; if let Some(container) = self.get_container_by_id(id) { - container.last_updated = Self::get_systemtime(); - let current_len = container.logs.len(); + if !container.is_oxker { + container.last_updated = Self::get_systemtime(); + let current_len = container.logs.len(); - for mut i in logs { - let tz = LogsTz::from(i.as_str()); - // Strip the timestamp if `-t` flag set - if !timestamp { - i = i.replace(&tz.to_string(), ""); + for mut i in logs { + let tz = LogsTz::from(i.as_str()); + // Strip the timestamp if `-t` flag set + if !timestamp { + i = i.replace(&tz.to_string(), ""); + } + let lines = if color { + log_sanitizer::colorize_logs(&i) + } else if raw { + log_sanitizer::raw(&i) + } else { + log_sanitizer::remove_ansi(&i) + }; + container.logs.insert(ListItem::new(lines), tz); } - let lines = if color { - log_sanitizer::colorize_logs(&i) - } else if raw { - log_sanitizer::raw(&i) - } else { - log_sanitizer::remove_ansi(&i) - }; - container.logs.insert(ListItem::new(lines), tz); - } - // Set the logs selected row for each container - // Either when no long currently selected, or currently selected (before updated) is already at end - if container.logs.state().selected().is_none() - || container.logs.state().selected().map_or(1, |f| f + 1) == current_len - { - container.logs.end(); + // Set the logs selected row for each container + // Either when no long currently selected, or currently selected (before updated) is already at end + if container.logs.state().selected().is_none() + || container.logs.state().selected().map_or(1, |f| f + 1) == current_len + { + container.logs.end(); + } } } } diff --git a/src/app_error.rs b/src/app_error.rs index f580b06..ba0d66f 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -6,6 +6,8 @@ use std::fmt; #[derive(Debug, Clone, Copy)] pub enum AppError { DockerCommand(DockerControls), + DockerExec, + DockerLogs, DockerConnect, DockerInterval, InputPoll, @@ -18,6 +20,8 @@ impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::DockerCommand(s) => write!(f, "Unable to {s} container"), + Self::DockerExec => write!(f, "Unable to exec into container"), + Self::DockerLogs => write!(f, "Unable to save logs"), Self::DockerConnect => write!(f, "Unable to access docker daemon"), Self::DockerInterval => write!(f, "Docker update interval needs to be greater than 0"), Self::InputPoll => write!(f, "Unable to poll user input"), @@ -25,7 +29,7 @@ impl fmt::Display for AppError { let reason = if *x { "en" } else { "dis" }; write!(f, "Unable to {reason}able mouse capture") } - Self::Terminal => write!(f, "Unable to draw to terminal"), + Self::Terminal => write!(f, "Unable to fully render to terminal"), } } } diff --git a/src/docker_data/message.rs b/src/docker_data/message.rs index 0a6b67e..e1066c1 100644 --- a/src/docker_data/message.rs +++ b/src/docker_data/message.rs @@ -1,9 +1,14 @@ -use crate::app_data::ContainerId; +use std::sync::Arc; -#[derive(Debug, Clone)] +use crate::app_data::ContainerId; +use bollard::Docker; +use tokio::sync::oneshot::Sender; + +#[derive(Debug)] pub enum DockerMessage { - Delete(ContainerId), ConfirmDelete(ContainerId), + Delete(ContainerId), + Exec(Sender>), Pause(ContainerId), Quit, Restart(ContainerId), diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 1540597..99d0bcf 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -10,13 +10,19 @@ use futures_util::StreamExt; use parking_lot::Mutex; use std::{ collections::HashMap, - sync::{atomic::AtomicBool, Arc}, + sync::{ + atomic::{AtomicBool, AtomicUsize}, + Arc, + }, +}; +use tokio::{ + sync::mpsc::{Receiver, Sender}, + task::JoinHandle, }; -use tokio::{sync::mpsc::Receiver, task::JoinHandle}; use uuid::Uuid; use crate::{ - app_data::{AppData, ContainerId, DockerControls}, + app_data::{AppData, ContainerId, DockerControls, State}, app_error::AppError, parse_args::CliArgs, ui::{GuiState, Status}, @@ -54,10 +60,10 @@ pub struct DockerData { app_data: Arc>, args: CliArgs, binate: Binate, - containerised: bool, docker: Arc, gui_state: Arc>, is_running: Arc, + init: Option>, receiver: Receiver, spawns: Arc>>>, } @@ -97,66 +103,72 @@ impl DockerData { app_data: Arc>, docker: Arc, id: ContainerId, - is_running: bool, + init: Option<(Arc, usize)>, + state: State, spawn_id: SpawnId, spawns: Arc>>>, ) { - let mut stream = docker - .stats( - id.get(), - Some(StatsOptions { - stream: false, - one_shot: !is_running, - }), - ) - .take(1); + if state.is_alive() || init.is_some() { + let mut stream = docker + .stats( + id.get(), + Some(StatsOptions { + stream: false, + one_shot: false, + }), + ) + .take(1); - while let Some(Ok(stats)) = stream.next().await { - let mem_stat = stats.memory_stats.usage.unwrap_or_default(); - let mem_limit = stats.memory_stats.limit.unwrap_or_default(); + while let Some(Ok(stats)) = stream.next().await { + let mem_stat = if state.is_alive() { + Some(stats.memory_stats.usage.unwrap_or_default()) + } else { + None + }; - let op_key = stats - .networks - .as_ref() - .and_then(|networks| networks.keys().next().cloned()); + let mem_limit = stats.memory_stats.limit.unwrap_or_default(); - let cpu_stats = Self::calculate_usage(&stats); - - let (rx, tx) = if let Some(key) = op_key { - stats + let op_key = stats .networks - .unwrap_or_default() - .get(&key) - .map_or((0, 0), |f| (f.rx_bytes, f.tx_bytes)) - } else { - (0, 0) - }; + .as_ref() + .and_then(|networks| networks.keys().next().cloned()); + + let cpu_stats = if state.is_alive() { + Some(Self::calculate_usage(&stats)) + } else { + None + }; + let (rx, tx) = if let Some(key) = op_key { + stats + .networks + .unwrap_or_default() + .get(&key) + .map_or((0, 0), |f| (f.rx_bytes, f.tx_bytes)) + } else { + (0, 0) + }; - if is_running { - app_data.lock().update_stats( - &id, - Some(cpu_stats), - Some(mem_stat), - mem_limit, - rx, - tx, - ); - } else { app_data .lock() - .update_stats(&id, None, None, mem_limit, rx, tx); + .update_stats(&id, cpu_stats, mem_stat, mem_limit, rx, tx); } - spawns.lock().remove(&spawn_id); + } + spawns.lock().remove(&spawn_id); + if let Some((target, _)) = init { + target.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } } /// Update all stats, spawn each container into own tokio::spawn thread - fn update_all_container_stats(&mut self, all_ids: &[(bool, ContainerId)]) { - for (is_running, id) in all_ids { + fn update_all_container_stats(&mut self, all_ids: &[(State, ContainerId)]) { + for (state, id) in all_ids { + // let init = self.init.as_ref().map_or_else(|| None, |x| Some((Arc::clone(x), all_ids.len()))); let docker = Arc::clone(&self.docker); let app_data = Arc::clone(&self.app_data); let spawns = Arc::clone(&self.spawns); let spawn_id = SpawnId::Stats((id.clone(), self.binate)); + + let init = self.init.as_ref().map(|i| (Arc::clone(i), all_ids.len())); self.spawns .lock() .entry(spawn_id.clone()) @@ -165,7 +177,8 @@ impl DockerData { app_data, docker, id.clone(), - *is_running, + init, + *state, spawn_id, spawns, )) @@ -177,7 +190,7 @@ impl DockerData { /// Get all current containers, handle into ContainerItem in the app_data struct rather than here /// Just make sure that items sent are guaranteed to have an id /// If in a containerised runtime, will ignore any container that uses the `/app/oxker` as an entry point, unless the `-s` flag is set - pub async fn update_all_containers(&mut self) -> Vec<(bool, ContainerId)> { + pub async fn update_all_containers(&mut self) -> Vec<(State, ContainerId)> { let containers = self .docker .list_containers(Some(ListContainersOptions:: { @@ -191,7 +204,7 @@ impl DockerData { .into_iter() .filter_map(|f| match f.id { Some(_) => { - if self.containerised + if self.args.in_container && f.command .as_ref() .map_or(false, |c| c.starts_with(ENTRY_POINT)) @@ -212,13 +225,7 @@ impl DockerData { output .into_iter() .filter_map(|i| { - i.id.map(|id| { - ( - i.state == Some("running".to_owned()) - || i.state == Some("restarting".to_owned()), - ContainerId::from(id.as_str()), - ) - }) + i.id.map(|id| (State::from(i.state), ContainerId::from(id.as_str()))) }) .collect::>() } @@ -253,7 +260,7 @@ impl DockerData { } /// Update all logs, spawn each container into own tokio::spawn thread - fn init_all_logs(&mut self, all_ids: &[(bool, ContainerId)]) { + fn init_all_logs(&mut self, all_ids: &[(State, ContainerId)]) { for (_, id) in all_ids { let docker = Arc::clone(&self.docker); let app_data = Arc::clone(&self.app_data); @@ -275,6 +282,7 @@ impl DockerData { .lock() .entry(SpawnId::Log(container.id.clone())) .or_insert_with(|| { + // MAYBE make a struct that can create this data? let app_data = Arc::clone(&self.app_data); let docker = Arc::clone(&self.docker); let id = container.id.clone(); @@ -286,44 +294,26 @@ impl DockerData { self.app_data.lock().sort_containers(); } - /// Animate the loading icon - fn loading_spin(loading_uuid: Uuid, gui_state: &Arc>) -> JoinHandle<()> { - let gui_state = Arc::clone(gui_state); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - gui_state.lock().next_loading(loading_uuid); - } - }) - } - - /// Stop the loading_spin function, and reset gui loading status - fn stop_loading_spin( - gui_state: &Arc>, - handle: &JoinHandle<()>, - loading_uuid: Uuid, - ) { - handle.abort(); - gui_state.lock().remove_loading(loading_uuid); - } - /// Initialize docker container data, before any messages are received async fn initialise_container_data(&mut self) { self.gui_state.lock().status_push(Status::Init); let loading_uuid = Uuid::new_v4(); - let loading_spin = Self::loading_spin(loading_uuid, &Arc::clone(&self.gui_state)); - + let loading_handle = GuiState::start_loading_animation(&self.gui_state, loading_uuid); let all_ids = self.update_all_containers().await; self.update_all_container_stats(&all_ids); self.init_all_logs(&all_ids); - // wait until all logs have initialised - while !self.app_data.lock().initialised(&all_ids) { + while let Some(x) = self.init.as_ref() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if x.load(std::sync::atomic::Ordering::SeqCst) == all_ids.len() { + self.init = None; + } } - Self::stop_loading_spin(&self.gui_state, &loading_spin, loading_uuid); + self.gui_state + .lock() + .stop_loading_animation(&loading_handle, loading_uuid); self.gui_state.lock().status_del(Status::Init); } @@ -340,6 +330,7 @@ impl DockerData { /// Handle incoming messages, container controls & all container information update /// Spawn Docker commands off into own thread + #[allow(clippy::too_many_lines)] async fn message_handler(&mut self) { while let Some(message) = self.receiver.recv().await { let docker = Arc::clone(&self.docker); @@ -348,29 +339,32 @@ impl DockerData { let uuid = Uuid::new_v4(); // TODO need to refactor these match message { + DockerMessage::Exec(docker_tx) => { + docker_tx.send(Arc::clone(&self.docker)).ok(); + } DockerMessage::Pause(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker.pause_container(id.get()).await.is_err() { Self::set_error(&app_data, DockerControls::Pause, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; } DockerMessage::Restart(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker.restart_container(id.get(), None).await.is_err() { Self::set_error(&app_data, DockerControls::Restart, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; } DockerMessage::Start(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker .start_container(id.get(), None::>) .await @@ -378,33 +372,33 @@ impl DockerData { { Self::set_error(&app_data, DockerControls::Start, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; } DockerMessage::Stop(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker.stop_container(id.get(), None).await.is_err() { Self::set_error(&app_data, DockerControls::Stop, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; } DockerMessage::Unpause(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker.unpause_container(id.get()).await.is_err() { Self::set_error(&app_data, DockerControls::Unpause, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; } DockerMessage::Delete(id) => { tokio::spawn(async move { - let loading_spin = Self::loading_spin(uuid, &gui_state); + let handle = GuiState::start_loading_animation(&gui_state, uuid); if docker .remove_container( id.get(), @@ -419,7 +413,7 @@ impl DockerData { { Self::set_error(&app_data, DockerControls::Stop, &gui_state); } - Self::stop_loading_spin(&gui_state, &loading_spin, uuid); + gui_state.lock().stop_loading_animation(&handle, uuid); }); self.update_everything().await; self.gui_state.lock().set_delete_container(None); @@ -440,12 +434,26 @@ impl DockerData { } } + /// Send an update message every x ms, where x is the args.docker_interval + fn croner(args: &CliArgs, docker_tx: Sender) { + let update_duration = std::time::Duration::from_millis(u64::from(args.docker_interval)); + let mut now = std::time::Instant::now(); + tokio::spawn(async move { + loop { + let to_sleep = update_duration.saturating_sub(now.elapsed()); + tokio::time::sleep(to_sleep).await; + docker_tx.send(DockerMessage::Update).await.ok(); + now = std::time::Instant::now(); + } + }); + } + /// Initialise self, and start the message receiving loop pub async fn init( app_data: Arc>, - containerised: bool, docker: Docker, docker_rx: Receiver, + docker_tx: Sender, gui_state: Arc>, is_running: Arc, ) { @@ -453,16 +461,17 @@ impl DockerData { if app_data.lock().get_error().is_none() { let mut inner = Self { app_data, - containerised, - args, + args: args.clone(), binate: Binate::One, docker: Arc::new(docker), gui_state, + init: Some(Arc::new(AtomicUsize::new(0))), is_running, receiver: docker_rx, spawns: Arc::new(Mutex::new(HashMap::new())), }; inner.initialise_container_data().await; + Self::croner(&args, docker_tx); inner.message_handler().await; } } diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..cb81c28 --- /dev/null +++ b/src/exec.rs @@ -0,0 +1,345 @@ +use std::{ + io::{Read, Stdout, Write}, + sync::{atomic::AtomicBool, Arc}, +}; + +use bollard::{ + exec::{CreateExecOptions, ResizeExecOptions, StartExecOptions, StartExecResults}, + Docker, +}; +use crossterm::terminal::enable_raw_mode; +use futures_util::StreamExt; +use parking_lot::Mutex; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::{ + app_data::{AppData, ContainerId, State}, + app_error::AppError, +}; + +/// TTY location +const TTY: &str = "/dev/tty"; + +/// This will be the start of a docker exec emssage if one is unable to actually exec into the container +const OCI_ERROR: &str = "OCI runtime exec failed"; + +/// Set the cursor position on the screen to (0,0) +const CURSOR_POS: &str = "\x1B[J\x1B[H"; + +/// This needs to be written to stdout when exiting the exec mode, else the input handler thread gets confused, +/// see https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement +const KEYBOARD_PROTO: &str = "\x1B[?u\x1B[c"; + +mod command { + pub const PWD: &str = "pwd"; + pub const DOCKER: &str = "docker"; + pub const EXEC: &str = "exec"; + pub const SH: &str = "sh"; + pub const IT: &str = "-it"; +} + +/// Currently known byte output after writing KEYBOARD_PROTO to stdout +/// valid arm: [91, 63, 54, 49, 59, 54, 59, 55, 59, 50, 50, 59, 50, 51, 59, 50, 52, 59, 50, 56, 59, 51, 50, 59,52, 50] => [?61;6;7;22;23;24;28;32;2 +/// valid x86: [91, 63, 49, 59, 50, 99] => [?1;2c +/// invalid x86: [91, 63, 49, 59, 48, 99] => [?1;0c +enum ByteOutput { + Arm, + X86, +} + +impl ByteOutput { + const fn len(&self) -> usize { + match self { + Self::Arm => 26, + Self::X86 => 6, + } + } + const fn last(&self) -> &[u8] { + match self { + Self::Arm => &[50], + Self::X86 => &[99], + } + } +} + +/// Check the output from tty to see if it matches known sequence. +/// At the moment we only need to check the length and end digit, as x86 valid and invalid match in these two regards +fn byte_sequence_valid(bytes: &[u8]) -> bool { + [ByteOutput::Arm, ByteOutput::X86] + .iter() + .any(|i| i.len() == bytes.len() && bytes.ends_with(i.last())) +} + +/// Check if tty is able to be written to, aka not windows +pub fn tty_readable() -> bool { + std::fs::OpenOptions::new() + .read(true) + .write(false) + .open(TTY) + .is_ok() +} + +/// Async tty reading, spawned into its own tokio thread +fn tty(run: Arc) -> Option { + if tty_readable() { + let (tx, rx) = std::sync::mpsc::channel(); + tokio::spawn(async move { + if let Ok(mut f) = tokio::fs::File::open(TTY).await { + while run.load(std::sync::atomic::Ordering::SeqCst) { + let mut buf = [0]; + if tokio::time::timeout( + std::time::Duration::from_millis(10), + f.read_exact(&mut buf), + ) + .await + .is_ok() + && tx.send(buf[0]).is_err() + { + run.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + } + }); + Some(AsyncTTY { rx }) + } else { + None + } +} + +struct AsyncTTY { + rx: std::sync::mpsc::Receiver, +} + +/// This is used to set the terminal size when exec via the Internal method +#[derive(Debug, Clone)] +pub struct TerminalSize { + width: u16, + height: u16, +} + +impl TerminalSize { + pub fn new(terminal: &Terminal>) -> Option { + terminal.size().map_or(None, |i| { + Some(Self { + width: i.width, + height: i.height, + }) + }) + } +} + +#[derive(Debug, Clone)] +pub enum ExecMode { + // use Bollard Rust library + Internal((ContainerId, Arc)), + // use the external `docker-cli` + External(ContainerId), +} + +impl ExecMode { + /// Test if we can exec into the selected container, first via the Internal methods, then by the External + /// If the container is oxker, it will always return None + pub async fn new(app_data: &Arc>, docker: &Arc) -> Option { + let is_oxker = app_data.lock().is_oxker(); + if is_oxker { + return None; + } + + let use_cli = app_data.lock().args.use_cli; + let container = app_data.lock().get_selected_container_id_state_name(); + + if let Some((id, state, _)) = container { + if state == State::Running { + if tty_readable() && !use_cli { + if let Ok(exec) = docker + .create_exec( + id.get(), + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(true), + cmd: Some(vec![command::PWD]), + ..Default::default() + }, + ) + .await + { + if let Ok(StartExecResults::Attached { mut output, .. }) = + docker.start_exec(&exec.id, None).await + { + if let Some(Ok(msg)) = output.next().await { + if !msg.to_string().starts_with(OCI_ERROR) { + return Some(Self::Internal((id.clone(), Arc::clone(docker)))); + } + } + } + } + } + + if let Ok(output) = std::process::Command::new(command::DOCKER) + .args([command::EXEC, id.get(), command::PWD]) + .output() + { + if let Ok(output) = String::from_utf8(output.stdout) { + if !output.starts_with(OCI_ERROR) { + return Some(Self::External(id.clone())); + } + } + } + } + } + None + } + + /// exec into the container using the external docker cli, the result it just piped into oxker + fn exec_external(id: &ContainerId) { + let mut stdout = std::io::stdout(); + stdout.write_all(CURSOR_POS.as_bytes()).ok(); + if let Ok(mut child) = std::process::Command::new(command::DOCKER) + .args([command::EXEC, command::IT, id.get(), command::SH]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + child.wait().ok(); + if child.kill().is_err() { + std::process::exit(1) + } + } + } + + /// Exec into the container via the Bollard library, stdout & stdin on different threads + /// Have to deal with strange output once dropped, hence the use of internal_cleanup() method + async fn exec_internal( + &self, + id: &ContainerId, + docker: &Arc, + terminal_size: Option, + ) -> Result<(), AppError> { + let run = Arc::new(AtomicBool::new(true)); + + if let Ok(exec_result) = docker + .create_exec( + id.get(), + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(false), + attach_stdin: Some(true), + tty: Some(true), + cmd: Some(vec![command::SH]), + ..Default::default() + }, + ) + .await + { + if let Ok(StartExecResults::Attached { + mut output, + mut input, + }) = docker + .start_exec( + &exec_result.id, + Some(StartExecOptions { + detach: false, + ..Default::default() + }), + ) + .await + { + if let Some(async_tty) = tty(Arc::clone(&run)) { + let run_thread = Arc::clone(&run); + tokio::spawn(async move { + enable_raw_mode().ok(); + let mut stdout = std::io::stdout(); + stdout.write_all(CURSOR_POS.as_bytes()).ok(); + stdout.flush().ok(); + + while run_thread.load(std::sync::atomic::Ordering::SeqCst) { + while let Some(Ok(x)) = output.next().await { + stdout.write_all(&x.into_bytes()).ok(); + stdout.flush().ok(); + } + run_thread.store(false, std::sync::atomic::Ordering::SeqCst); + } + }); + + if let Some(terminal_size) = terminal_size { + docker + .resize_exec( + &exec_result.id, + ResizeExecOptions { + height: terminal_size.height, + width: terminal_size.width, + }, + ) + .await + .ok(); + } + + while let Ok(x) = async_tty.rx.recv() { + input.write(&[x]).await.ok(); + } + + self.internal_cleanup()?; + } + } else { + return Err(AppError::Terminal); + } + } + Ok(()) + } + + // This is the fix for key pressed not being handled correctly on quit + // It writes a special message to the stdout, and then listens out for a valid response + // afterwhich it's assumes that we're completely done with TTY + fn internal_cleanup(&self) -> Result<(), AppError> { + match self { + Self::External(_) => Ok(()), + Self::Internal(_) => { + let waiting = Arc::new(AtomicBool::new(true)); + let waiting_thread = Arc::clone(&waiting); + + std::thread::spawn(move || { + let mut bytes = Vec::with_capacity(26); + while waiting_thread.load(std::sync::atomic::Ordering::SeqCst) { + let mut buf = [0]; + if let Ok(mut f) = std::fs::File::open(TTY) { + if f.read_exact(&mut buf).is_err() { + waiting_thread.store(false, std::sync::atomic::Ordering::SeqCst); + } + bytes.push(buf[0]); + if byte_sequence_valid(&bytes) { + waiting_thread.store(false, std::sync::atomic::Ordering::SeqCst); + } + }; + } + }); + + let mut stdout = std::io::stdout(); + stdout.write_all(KEYBOARD_PROTO.as_bytes()).ok(); + stdout.flush().ok(); + + let start = std::time::Instant::now(); + while waiting.load(std::sync::atomic::Ordering::SeqCst) { + if start.elapsed().as_millis() > 1500 { + waiting.store(false, std::sync::atomic::Ordering::SeqCst); + return Err(AppError::Terminal); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Ok(()) + } + } + } + + pub async fn run(&self, tty_size: Option) -> Result<(), AppError> { + match self { + Self::External(id) => { + Self::exec_external(id); + Ok(()) + } + + Self::Internal((id, docker)) => self.exec_internal(id, docker, tty_size).await, + } + } +} diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index c05164c..d226f67 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -1,26 +1,32 @@ -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, +use std::{ + fs::OpenOptions, + io::{BufWriter, Write}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::SystemTime, }; +use bollard::{container::LogsOptions, Docker}; +use cansi::v3::categorise_text; use crossterm::{ event::{DisableMouseCapture, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, execute, }; +use futures_util::StreamExt; use parking_lot::Mutex; use ratatui::layout::Rect; -use tokio::{ - sync::mpsc::{Receiver, Sender}, - task::JoinHandle, -}; +use tokio::sync::mpsc::{Receiver, Sender}; +use uuid::Uuid; mod message; use crate::{ app_data::{AppData, DockerControls, Header}, app_error::AppError, docker_data::DockerMessage, + exec::{tty_readable, ExecMode}, ui::{DeleteButton, GuiState, SelectablePanel, Status, Ui}, - value_capture, }; pub use message::InputMessages; @@ -28,9 +34,8 @@ pub use message::InputMessages; #[derive(Debug)] pub struct InputHandler { app_data: Arc>, - docker_sender: Sender, + docker_tx: Sender, gui_state: Arc>, - info_sleep: Option>, is_running: Arc, mouse_capture: bool, rec: Receiver, @@ -41,18 +46,17 @@ impl InputHandler { pub async fn init( app_data: Arc>, rec: Receiver, - docker_sender: Sender, + docker_tx: Sender, gui_state: Arc>, is_running: Arc, ) { let mut inner = Self { app_data, - docker_sender, + docker_tx, gui_state, is_running, rec, mouse_capture: true, - info_sleep: None, }; inner.start().await; } @@ -85,6 +89,64 @@ impl InputHandler { } } + /// Sort the containers by a given header + fn sort(&self, selected_header: Header) { + self.app_data.lock().set_sort_by_header(selected_header); + } + + /// Send a quit message to docker, to abort all spawns, if an error is returned, set is_running to false here instead + /// If gui_status is Error or Init, then just set the is_running to false immediately, for a quicker exit + async fn quit(&self) { + let error_init = self + .gui_state + .lock() + .status_contains(&[Status::Error, Status::Init]); + if error_init || self.docker_tx.send(DockerMessage::Quit).await.is_err() { + self.is_running + .store(false, std::sync::atomic::Ordering::SeqCst); + } + } + + /// This is executed from the Delete Confirm dialog, and will send an internal message to actually remove the given container + async fn confirm_delete(&self) { + let id = self.gui_state.lock().get_delete_container(); + if let Some(id) = id { + self.docker_tx.send(DockerMessage::Delete(id)).await.ok(); + } + } + + /// This is executed from the Delete Confirm dialog, and will clear the delete_container information (removes id and closes panel) + fn clear_delete(&self) { + self.gui_state.lock().set_delete_container(None); + } + + /// Validate that one can exec into a Docker container + async fn e_key(&self) { + let is_oxker = self.app_data.lock().is_oxker(); + if !is_oxker && tty_readable() { + let uuid = Uuid::new_v4(); + let handle = GuiState::start_loading_animation(&self.gui_state, uuid); + let (sx, rx) = tokio::sync::oneshot::channel::>(); + self.docker_tx.send(DockerMessage::Exec(sx)).await.ok(); + + if let Ok(docker) = rx.await { + (ExecMode::new(&self.app_data, &docker).await).map_or_else( + || { + self.app_data.lock().set_error( + AppError::DockerExec, + &self.gui_state, + Status::Error, + ); + }, + |mode| { + self.gui_state.lock().set_exec_mode(mode); + }, + ); + } + self.gui_state.lock().stop_loading_animation(&handle, uuid); + } + } + /// Toggle the mouse capture (via input of the 'm' key) fn m_key(&mut self) { if self.mouse_capture { @@ -111,213 +173,258 @@ impl InputHandler { ); }; - // If the info box sleep handle is currently being executed, as in 'm' is pressed twice within a 4000ms window - // then cancel the first handle, as a new handle will be invoked - if let Some(info_sleep_timer) = self.info_sleep.as_ref() { - info_sleep_timer.abort(); - } - - let gui_state = Arc::clone(&self.gui_state); - // Show the info box - with "mouse capture enabled / disabled", for 4000 ms - self.info_sleep = Some(tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(4000)).await; - gui_state.lock().reset_info_box(); - })); - self.mouse_capture = !self.mouse_capture; } - /// Sort the containers by a given header - fn sort(&self, selected_header: Header) { - self.app_data.lock().set_sort_by_header(selected_header); - } + /// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file + async fn s_key(&mut self) { + /// This is the inner workings, *inlined* here to return a Result + async fn save_logs( + app_data: &Arc>, + gui_state: &Arc>, + docker_tx: &Sender, + ) -> Result<(), Box> { + let args = app_data.lock().args.clone(); + let container = app_data.lock().get_selected_container_id_state_name(); + if let Some((id, _, name)) = container { + if let Some(log_path) = args.save_dir { + let (sx, rx) = tokio::sync::oneshot::channel::>(); + docker_tx.send(DockerMessage::Exec(sx)).await?; - /// Send a quit message to docker, to abort all spawns, if an error is returned, set is_running to false here instead - /// If gui_status is Error or Init, then just set the is_running to false immediately, for a quicker exit - async fn quit(&self) { - let error_init = self - .gui_state - .lock() - .status_contains(&[Status::Error, Status::Init]); - if error_init || self.docker_sender.send(DockerMessage::Quit).await.is_err() { - self.is_running - .store(false, std::sync::atomic::Ordering::SeqCst); - } - } + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |i| i.as_secs()); - /// This is executed from the Delete Confirm dialog, and will send an internal message to actually remove the given container - async fn confirm_delete(&self) { - let id = self.gui_state.lock().get_delete_container(); - if let Some(id) = id { - self.docker_sender - .send(DockerMessage::Delete(id)) - .await - .ok(); - } - } + let path = log_path.join(format!("{name}_{now}.log")); - /// This is executed from the Delete Confirm dialog, and will clear the delete_container information (removes id and closes panel) - fn clear_delete(&self) { - self.gui_state.lock().set_delete_container(None); - } + let docker = rx.await?; + let options = Some(LogsOptions:: { + stdout: true, + timestamps: args.timestamp, + since: 0, + ..Default::default() + }); + let mut logs = docker.logs(id.get(), options); + let mut output = vec![]; - /// Handle any keyboard button events - #[allow(clippy::too_many_lines)] - async fn button_press(&mut self, key_code: KeyCode, key_modififer: KeyModifiers) { - value_capture!( - contains_delete, - self.gui_state - .lock() - .status_contains(&[Status::DeleteConfirm]) - ); - - value_capture!( - contains_error, - self.gui_state.lock().status_contains(&[Status::Error]) - ); - value_capture!( - contains_help, - self.gui_state.lock().status_contains(&[Status::Help]) - ); - - // Always just quit on Ctrl + c/C or q/Q - let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C'); - let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q'); - if key_modififer == KeyModifiers::CONTROL && is_c() || is_q() { - self.quit().await; - } - - if contains_error { - if let KeyCode::Char('c' | 'C') = key_code { - self.app_data.lock().remove_error(); - self.gui_state.lock().status_del(Status::Error); - } - } else if contains_help { - match key_code { - KeyCode::Char('h' | 'H') => self.gui_state.lock().status_del(Status::Help), - KeyCode::Char('m' | 'M') => self.m_key(), - _ => (), - } - } else if contains_delete { - match key_code { - KeyCode::Char('y' | 'Y') => self.confirm_delete().await, - KeyCode::Char('n' | 'N') => self.clear_delete(), - _ => (), - } - } else { - match key_code { - KeyCode::Char('0') => self.app_data.lock().reset_sorted(), - KeyCode::Char('1') => self.sort(Header::State), - KeyCode::Char('2') => self.sort(Header::Status), - KeyCode::Char('3') => self.sort(Header::Cpu), - KeyCode::Char('4') => self.sort(Header::Memory), - KeyCode::Char('5') => self.sort(Header::Id), - KeyCode::Char('6') => self.sort(Header::Name), - KeyCode::Char('7') => self.sort(Header::Image), - KeyCode::Char('8') => self.sort(Header::Rx), - KeyCode::Char('9') => self.sort(Header::Tx), - KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help), - KeyCode::Char('m' | 'M') => self.m_key(), - KeyCode::Tab => { - // Skip control panel if no containers, could be refactored - let is_containers = - self.gui_state.lock().selected_panel == SelectablePanel::Containers; - let count = if self.app_data.lock().get_container_len() == 0 && is_containers { - 2 - } else { - 1 - }; - for _ in 0..count { - self.gui_state.lock().next_panel(); - } - } - KeyCode::BackTab => { - // Skip control panel if no containers, could be refactored - let is_containers = - self.gui_state.lock().selected_panel == SelectablePanel::Logs; - let count = if self.app_data.lock().get_container_len() == 0 && is_containers { - 2 - } else { - 1 - }; - for _ in 0..count { - self.gui_state.lock().previous_panel(); - } - } - KeyCode::Home => { - let mut locked_data = self.app_data.lock(); - let selected_panel = self.gui_state.lock().selected_panel; - match selected_panel { - SelectablePanel::Containers => locked_data.containers_start(), - SelectablePanel::Logs => locked_data.log_start(), - SelectablePanel::Commands => locked_data.docker_command_start(), - } - } - KeyCode::End => { - let mut locked_data = self.app_data.lock(); - let selected_panel = self.gui_state.lock().selected_panel; - match selected_panel { - SelectablePanel::Containers => locked_data.containers_end(), - SelectablePanel::Logs => locked_data.log_end(), - SelectablePanel::Commands => locked_data.docker_command_end(), - } - } - KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(), - KeyCode::PageUp => { - for _ in 0..=6 { - self.previous(); - } - } - KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(), - KeyCode::PageDown => { - for _ in 0..=6 { - self.next(); - } - } - KeyCode::Enter => { - // This isn't great, just means you can't send docker commands before full initialization of the program - let panel = self.gui_state.lock().selected_panel; - if panel == SelectablePanel::Commands { - let option_command = self.app_data.lock().selected_docker_command(); - - if let Some(command) = option_command { - let option_id = self.app_data.lock().get_selected_container_id(); - // Poor way of disallowing commands to be sent to a containerised okxer - if self.app_data.lock().is_oxker() { - return; - }; - if let Some(id) = option_id { - match command { - DockerControls::Delete => self - .docker_sender - .send(DockerMessage::ConfirmDelete(id)) - .await - .ok(), - DockerControls::Pause => { - self.docker_sender.send(DockerMessage::Pause(id)).await.ok() - } - DockerControls::Unpause => self - .docker_sender - .send(DockerMessage::Unpause(id)) - .await - .ok(), - DockerControls::Start => { - self.docker_sender.send(DockerMessage::Start(id)).await.ok() - } - DockerControls::Stop => { - self.docker_sender.send(DockerMessage::Stop(id)).await.ok() - } - DockerControls::Restart => self - .docker_sender - .send(DockerMessage::Restart(id)) - .await - .ok(), - }; - } + while let Some(Ok(value)) = logs.next().await { + let data = value.to_string(); + if !data.trim().is_empty() { + output.push( + categorise_text(&data) + .into_iter() + .map(|i| i.text) + .collect::(), + ); } } + if !output.is_empty() { + let mut stream = BufWriter::new( + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?, + ); + + for line in &output { + stream.write_all(line.as_bytes())?; + } + stream.flush()?; + + gui_state + .lock() + .set_info_box(&format!("saved to {}", path.display())); + } + } + } + Ok(()) + } + + let log_status = Status::Logs; + let status = self.gui_state.lock().status_contains(&[log_status]); + if !status { + self.gui_state.lock().status_push(log_status); + + let uuid = Uuid::new_v4(); + let handle = GuiState::start_loading_animation(&self.gui_state, uuid); + if save_logs(&self.app_data, &self.gui_state, &self.docker_tx) + .await + .is_err() + { + self.app_data.lock().set_error( + AppError::DockerLogs, + &self.gui_state, + Status::Error, + ); + } + self.gui_state.lock().status_del(log_status); + self.gui_state.lock().stop_loading_animation(&handle, uuid); + } + } + + /// Send docker command, if the Commands panel is selected + async fn enter_key(&mut self) { + // This isn't great, just means you can't send docker commands before full initialization of the program + let panel = self.gui_state.lock().get_selected_panel(); + if panel == SelectablePanel::Commands { + let option_command = self.app_data.lock().selected_docker_command(); + + if let Some(command) = option_command { + // Poor way of disallowing commands to be sent to a containerised okxer + if self.app_data.lock().is_oxker() { + return; + }; + let option_id = self.app_data.lock().get_selected_container_id(); + if let Some(id) = option_id { + match command { + DockerControls::Delete => self + .docker_tx + .send(DockerMessage::ConfirmDelete(id)) + .await + .ok(), + DockerControls::Pause => { + self.docker_tx.send(DockerMessage::Pause(id)).await.ok() + } + DockerControls::Unpause => { + self.docker_tx.send(DockerMessage::Unpause(id)).await.ok() + } + DockerControls::Start => { + self.docker_tx.send(DockerMessage::Start(id)).await.ok() + } + DockerControls::Stop => { + self.docker_tx.send(DockerMessage::Stop(id)).await.ok() + } + DockerControls::Restart => { + self.docker_tx.send(DockerMessage::Restart(id)).await.ok() + } + }; + } + } + } + } + + /// Change the the "next" seletable panel + fn tab_key(&mut self) { + let is_containers = + self.gui_state.lock().get_selected_panel() == SelectablePanel::Containers; + let count = if self.app_data.lock().get_container_len() == 0 && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().next_panel(); + } + } + + /// Change to previously selected panel + fn back_tab_key(&mut self) { + let is_containers = self.gui_state.lock().get_selected_panel() == SelectablePanel::Logs; + let count = if self.app_data.lock().get_container_len() == 0 && is_containers { + 2 + } else { + 1 + }; + for _ in 0..count { + self.gui_state.lock().previous_panel(); + } + } + + fn home_key(&mut self) { + let mut locked_data = self.app_data.lock(); + let selected_panel = self.gui_state.lock().get_selected_panel(); + match selected_panel { + SelectablePanel::Containers => locked_data.containers_start(), + SelectablePanel::Logs => locked_data.log_start(), + SelectablePanel::Commands => locked_data.docker_command_start(), + } + } + + /// Go to end of the list of the currently selected panel + fn end_key(&mut self) { + let mut locked_data = self.app_data.lock(); + let selected_panel = self.gui_state.lock().get_selected_panel(); + match selected_panel { + SelectablePanel::Containers => locked_data.containers_end(), + SelectablePanel::Logs => locked_data.log_end(), + SelectablePanel::Commands => locked_data.docker_command_end(), + } + } + + /// Handle keyboard button events + async fn button_press(&mut self, key_code: KeyCode, key_modififer: KeyModifiers) { + let contains_delete = self + .gui_state + .lock() + .status_contains(&[Status::DeleteConfirm]); + + let contains = |s: Status| self.gui_state.lock().status_contains(&[s]); + + let contains_error = contains(Status::Error); + let contains_help = contains(Status::Help); + let contains_exec = contains(Status::Exec); + + if !contains_exec { + // Always just quit on Ctrl + c/C or q/Q + let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C'); + let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q'); + if key_modififer == KeyModifiers::CONTROL && is_c() || is_q() { + self.quit().await; + } + + if contains_error { + if let KeyCode::Char('c' | 'C') = key_code { + self.app_data.lock().remove_error(); + self.gui_state.lock().status_del(Status::Error); + } + } else if contains_help { + match key_code { + KeyCode::Char('h' | 'H') => self.gui_state.lock().status_del(Status::Help), + KeyCode::Char('m' | 'M') => self.m_key(), + _ => (), + } + } else if contains_delete { + match key_code { + KeyCode::Char('y' | 'Y') => self.confirm_delete().await, + KeyCode::Char('n' | 'N') => self.clear_delete(), + _ => (), + } + } else { + match key_code { + KeyCode::Char('0') => self.app_data.lock().reset_sorted(), + KeyCode::Char('1') => self.sort(Header::State), + KeyCode::Char('2') => self.sort(Header::Status), + KeyCode::Char('3') => self.sort(Header::Cpu), + KeyCode::Char('4') => self.sort(Header::Memory), + KeyCode::Char('5') => self.sort(Header::Id), + KeyCode::Char('6') => self.sort(Header::Name), + KeyCode::Char('7') => self.sort(Header::Image), + KeyCode::Char('8') => self.sort(Header::Rx), + KeyCode::Char('9') => self.sort(Header::Tx), + KeyCode::Char('e' | 'E') => self.e_key().await, + KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help), + KeyCode::Char('m' | 'M') => self.m_key(), + KeyCode::Char('s' | 'S') => self.s_key().await, + KeyCode::Tab => self.tab_key(), + KeyCode::BackTab => self.back_tab_key(), + KeyCode::Home => self.home_key(), + KeyCode::End => self.end_key(), + KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(), + KeyCode::PageUp => { + for _ in 0..=6 { + self.previous(); + } + } + KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(), + KeyCode::PageDown => { + for _ in 0..=6 { + self.next(); + } + } + KeyCode::Enter => self.enter_key().await, + _ => (), } - _ => (), } } } @@ -370,7 +477,7 @@ impl InputHandler { /// Change state to next, depending which panel is currently in focus fn next(&mut self) { let mut locked_data = self.app_data.lock(); - let selected_panel = self.gui_state.lock().selected_panel; + let selected_panel = self.gui_state.lock().get_selected_panel(); match selected_panel { SelectablePanel::Containers => locked_data.containers_next(), SelectablePanel::Logs => locked_data.log_next(), @@ -381,7 +488,7 @@ impl InputHandler { /// Change state to previous, depending which panel is currently in focus fn previous(&mut self) { let mut locked_data = self.app_data.lock(); - let selected_panel = self.gui_state.lock().selected_panel; + let selected_panel = self.gui_state.lock().get_selected_panel(); match selected_panel { SelectablePanel::Containers => locked_data.containers_previous(), SelectablePanel::Logs => locked_data.log_previous(), diff --git a/src/main.rs b/src/main.rs index ef3e279..d976325 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,3 @@ -#![forbid(unsafe_code)] -#![warn( - clippy::expect_used, - clippy::nursery, - clippy::pedantic, - clippy::todo, - clippy::unused_async, - clippy::unwrap_used -)] -#![allow( - clippy::module_name_repetitions, - clippy::doc_markdown, - clippy::similar_names -)] // Only allow when debugging // #![allow(unused)] @@ -35,6 +21,7 @@ use tracing::{error, info, Level}; mod app_data; mod app_error; mod docker_data; +mod exec; mod input_handler; mod parse_args; mod ui; @@ -55,18 +42,6 @@ fn setup_tracing() { tracing_subscriber::fmt().with_max_level(Level::INFO).init(); } -/// An ENV is set in the ./containerised/Dockerfile, if this is ENV found, then sleep for 250ms, else the container, for as yet unknown reasons, will close immediately -/// returns a bool, so that the `update_all_containers()` won't bother to check the entry point unless running via a container -fn check_if_containerised() -> bool { - if let Ok(value) = std::env::var(ENV_KEY) { - if value == ENV_VALUE { - std::thread::sleep(std::time::Duration::from_millis(250)); - return true; - } - } - false -} - /// Read the optional docker_host path, the cli args take priority over the DOCKER_HOST env fn read_docker_host(args: &CliArgs) -> Option { args.host @@ -77,8 +52,8 @@ fn read_docker_host(args: &CliArgs) -> Option { /// Create docker daemon handler, and only spawn up the docker data handler if a ping returns non-error async fn docker_init( app_data: &Arc>, - containerised: bool, docker_rx: Receiver, + docker_tx: Sender, gui_state: &Arc>, is_running: &Arc, host: Option, @@ -92,13 +67,9 @@ async fn docker_init( let app_data = Arc::clone(app_data); let gui_state = Arc::clone(gui_state); let is_running = Arc::clone(is_running); + tokio::spawn(DockerData::init( - app_data, - containerised, - docker, - docker_rx, - gui_state, - is_running, + app_data, docker, docker_rx, docker_tx, gui_state, is_running, )); } else { app_data @@ -120,36 +91,40 @@ fn handler_init( input_rx: Receiver, is_running: &Arc, ) { - let input_app_data = Arc::clone(app_data); - let input_gui_state = Arc::clone(gui_state); - let input_is_running = Arc::clone(is_running); + let app_data = Arc::clone(app_data); + let gui_state = Arc::clone(gui_state); + let is_running = Arc::clone(is_running); tokio::spawn(input_handler::InputHandler::init( - input_app_data, + app_data, input_rx, docker_sx.clone(), - input_gui_state, - input_is_running, + gui_state, + is_running, )); } #[tokio::main] async fn main() { - let containerised = check_if_containerised(); - setup_tracing(); let args = CliArgs::new(); + + // If running via Docker image, need to sleep else program will just quit straight away, no real idea why + // So just sleep for small while + if args.in_container { + std::thread::sleep(std::time::Duration::from_millis(250)); + } let host = read_docker_host(&args); let app_data = Arc::new(Mutex::new(AppData::default(args.clone()))); let gui_state = Arc::new(Mutex::new(GuiState::default())); let is_running = Arc::new(AtomicBool::new(true)); - let (docker_sx, docker_rx) = tokio::sync::mpsc::channel(32); + let (docker_tx, docker_rx) = tokio::sync::mpsc::channel(32); docker_init( &app_data, - containerised, docker_rx, + docker_tx.clone(), &gui_state, &is_running, host, @@ -157,23 +132,34 @@ async fn main() { .await; if args.gui { - let (input_sx, input_rx) = tokio::sync::mpsc::channel(32); - handler_init(&app_data, &docker_sx, &gui_state, input_rx, &is_running); - Ui::create(app_data, docker_sx, gui_state, is_running, input_sx).await; + let (input_tx, input_rx) = tokio::sync::mpsc::channel(32); + handler_init(&app_data, &docker_tx, &gui_state, input_rx, &is_running); + Ui::create(app_data, gui_state, input_tx, is_running).await; } else { - info!("in debug mode"); - // Debug mode for testing, mostly pointless, doesn't take terminal + info!("in debug mode\n"); + // Debug mode for testing, less pointless now, will display some basic information while is_running.load(Ordering::SeqCst) { - loop { - if let Some(err) = app_data.lock().get_error() { - error!("{}", err); - process::exit(1); + if let Some(err) = app_data.lock().get_error() { + error!("{}", err); + process::exit(1); + } + tokio::time::sleep(std::time::Duration::from_millis(u64::from( + args.docker_interval, + ))) + .await; + let containers = app_data + .lock() + .get_container_items() + .clone() + .iter() + .map(|i| format!("{i}")) + .collect::>(); + + if !containers.is_empty() { + for item in containers { + info!("{item}"); } - docker_sx.send(DockerMessage::Update).await.ok(); - tokio::time::sleep(std::time::Duration::from_millis(u64::from( - args.docker_interval, - ))) - .await; + println!(); } } } diff --git a/src/parse_args.rs b/src/parse_args.rs index 7a69053..e29d84c 100644 --- a/src/parse_args.rs +++ b/src/parse_args.rs @@ -1,12 +1,14 @@ -use std::process; +use std::{path::PathBuf, process}; use clap::Parser; use tracing::error; +use crate::{ENV_KEY, ENV_VALUE}; + #[derive(Parser, Debug, Clone)] #[allow(clippy::struct_excessive_bools)] #[command(version, about)] -pub struct CliArgs { +pub struct Args { /// Docker update interval in ms, minimum effectively 1000 #[clap(short = 'd', value_name = "ms", default_value_t = 1000)] pub docker_interval: u32, @@ -19,10 +21,6 @@ pub struct CliArgs { #[clap(short = 'c', conflicts_with = "raw")] pub color: bool, - /// Docker host, defaults to `/var/run/docker.sock` - #[clap(long, short = None)] - pub host: Option, - /// Show raw logs, default is to remove ansi formatting, conflicts with "-c" #[clap(short = 'r', conflicts_with = "color")] pub raw: bool, @@ -34,12 +32,55 @@ pub struct CliArgs { /// Don't draw gui - for debugging - mostly pointless #[clap(short = 'g')] pub gui: bool, + + /// Docker host, defaults to `/var/run/docker.sock` + #[clap(long, short = None)] + pub host: Option, + + /// Force use of docker cli when execing into containers + #[clap(long="use-cli", short = None)] + pub use_cli: bool, + + /// Directory for saving exported logs, defaults to `$HOME` + #[clap(long="save-dir", short = None)] + pub save_dir: Option, +} + +#[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] +pub struct CliArgs { + pub color: bool, + pub docker_interval: u32, + pub gui: bool, + pub host: Option, + pub in_container: bool, + pub save_dir: Option, + pub raw: bool, + pub show_self: bool, + pub timestamp: bool, + pub use_cli: bool, } impl CliArgs { + /// An ENV is set in the ./containerised/Dockerfile, if this is ENV found, then sleep for 250ms, else the container, for as yet unknown reasons, will close immediately + /// returns a bool, so that the `update_all_containers()` won't bother to check the entry point unless running via a container + fn check_if_in_container() -> bool { + if let Ok(value) = std::env::var(ENV_KEY) { + if value == ENV_VALUE { + return true; + } + } + false + } + /// Parse cli arguments pub fn new() -> Self { - let args = Self::parse(); + let args = Args::parse(); + + let logs_dir = args.save_dir.map_or_else( + || directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned()), + |logs_dir| Some(std::path::Path::new(&logs_dir).to_owned()), + ); // Quit the program if the docker update argument is 0 // Should maybe change it to check if less than 100 @@ -50,10 +91,13 @@ impl CliArgs { Self { color: args.color, docker_interval: args.docker_interval, - host: args.host, + use_cli: args.use_cli, gui: !args.gui, - show_self: !args.show_self, + host: args.host, + in_container: Self::check_if_in_container(), + save_dir: logs_dir, raw: args.raw, + show_self: !args.show_self, timestamp: !args.timestamp, } } diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index 4080966..b38be52 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -1,6 +1,5 @@ use parking_lot::Mutex; use ratatui::{ - backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, symbols, @@ -11,17 +10,19 @@ use ratatui::{ }, Frame, }; -use std::default::Default; +use std::{default::Default, time::Instant}; use std::{fmt::Display, sync::Arc}; -use crate::app_data::{Header, SortedOrder}; -use crate::ui::Status; +use crate::app_data::{ContainerItem, Header, SortedOrder}; use crate::{ app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats}, app_error::AppError, }; -use super::gui_state::{BoxLocation, DeleteButton, Region}; +use super::{ + gui_state::{BoxLocation, DeleteButton, Region}, + FrameData, +}; use super::{GuiState, SelectablePanel}; const NAME_TEXT: &str = r#" @@ -40,7 +41,7 @@ const REPO: &str = env!("CARGO_PKG_REPOSITORY"); const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); const ORANGE: Color = Color::Rgb(255, 178, 36); const MARGIN: &str = " "; -const ARROW: &str = "▶ "; +const RIGHT_ARROW: &str = "▶ "; const CIRCLE: &str = "⚪ "; /// From a given &str, return the maximum number of chars on a single line @@ -56,6 +57,7 @@ fn max_line_width(text: &str) -> usize { fn generate_block<'a>( app_data: &Arc>, area: Rect, + fd: &FrameData, gui_state: &Arc>, panel: SelectablePanel, ) -> Block<'a> { @@ -78,20 +80,21 @@ fn generate_block<'a>( .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(title); - if gui_state.lock().selected_panel == panel { + if fd.selected_panel == panel { block = block.border_style(Style::default().fg(Color::LightCyan)); } block } /// Draw the command panel -pub fn commands( +pub fn commands( app_data: &Arc>, area: Rect, - f: &mut Frame<'_, B>, + f: &mut Frame, + fd: &FrameData, gui_state: &Arc>, ) { - let block = || generate_block(app_data, area, gui_state, SelectablePanel::Commands); + let block = || generate_block(app_data, area, fd, gui_state, SelectablePanel::Commands); let items = app_data.lock().get_control_items().map_or(vec![], |i| { i.iter() .map(|c| { @@ -107,7 +110,7 @@ pub fn commands( let items = List::new(items) .block(block()) .highlight_style(Style::default().add_modifier(Modifier::BOLD)) - .highlight_symbol(ARROW); + .highlight_symbol(RIGHT_ARROW); if let Some(i) = app_data.lock().get_control_state() { f.render_stateful_widget(items, area, i); @@ -119,88 +122,91 @@ pub fn commands( } } +/// Format the container data to display nicely on the screen +fn format_containers<'a>(i: &ContainerItem, widths: &Columns) -> Line<'a> { + let state_style = Style::default().fg(i.state.get_color()); + let blue = Style::default().fg(Color::Blue); + + Line::from(vec![ + Span::styled( + format!( + "{:width$}", + i.status, + width = &widths.status.1.into() + ), + state_style, + ), + Span::styled( + format!( + "{}{:>width$}", + MARGIN, + i.cpu_stats.back().unwrap_or(&CpuStats::default()), + width = &widths.cpu.1.into() + ), + state_style, + ), + Span::styled( + format!( + "{MARGIN}{:>width_current$} / {:>width_limit$}", + i.mem_stats.back().unwrap_or(&ByteStats::default()), + i.mem_limit, + width_current = &widths.mem.1.into(), + width_limit = &widths.mem.2.into() + ), + state_style, + ), + Span::styled( + format!( + "{}{:>width$}", + MARGIN, + i.id.get_short(), + width = &widths.id.1.into() + ), + blue, + ), + Span::styled( + format!("{MARGIN}{:>width$}", i.name, width = widths.name.1.into()), + blue, + ), + Span::styled( + format!("{MARGIN}{:>width$}", i.image, width = widths.image.1.into()), + blue, + ), + Span::styled( + format!("{MARGIN}{:>width$}", i.rx, width = widths.net_rx.1.into()), + Style::default().fg(Color::Rgb(255, 233, 193)), + ), + Span::styled( + format!("{MARGIN}{:>width$}", i.tx, width = widths.net_tx.1.into()), + Style::default().fg(Color::Rgb(205, 140, 140)), + ), + ]) +} + /// Draw the containers panel -pub fn containers( +pub fn containers( app_data: &Arc>, area: Rect, - f: &mut Frame<'_, B>, + f: &mut Frame, + fd: &FrameData, gui_state: &Arc>, widths: &Columns, ) { - let block = generate_block(app_data, area, gui_state, SelectablePanel::Containers); + let block = generate_block(app_data, area, fd, gui_state, SelectablePanel::Containers); let items = app_data .lock() .get_container_items() .iter() - .map(|i| { - let state_style = Style::default().fg(i.state.get_color()); - let blue = Style::default().fg(Color::Blue); - - let lines = Line::from(vec![ - Span::styled( - format!( - "{:width$}", - i.status, - width = &widths.status.1.into() - ), - state_style, - ), - Span::styled( - format!( - "{}{:>width$}", - MARGIN, - i.cpu_stats.back().unwrap_or(&CpuStats::default()), - width = &widths.cpu.1.into() - ), - state_style, - ), - Span::styled( - format!( - "{MARGIN}{:>width_current$} / {:>width_limit$}", - i.mem_stats.back().unwrap_or(&ByteStats::default()), - i.mem_limit, - width_current = &widths.mem.1.into(), - width_limit = &widths.mem.2.into() - ), - state_style, - ), - Span::styled( - format!( - "{}{:>width$}", - MARGIN, - i.id.get().chars().take(8).collect::(), - width = &widths.id.1.into() - ), - blue, - ), - Span::styled( - format!("{MARGIN}{:>width$}", i.name, width = widths.name.1.into()), - blue, - ), - Span::styled( - format!("{MARGIN}{:>width$}", i.image, width = widths.image.1.into()), - blue, - ), - Span::styled( - format!("{MARGIN}{:>width$}", i.rx, width = widths.net_rx.1.into()), - Style::default().fg(Color::Rgb(255, 233, 193)), - ), - Span::styled( - format!("{MARGIN}{:>width$}", i.tx, width = widths.net_tx.1.into()), - Style::default().fg(Color::Rgb(205, 140, 140)), - ), - ]); - ListItem::new(lines) - }) + .map(|i| ListItem::new(format_containers(i, widths))) .collect::>(); if items.is_empty() { @@ -213,22 +219,21 @@ pub fn containers( .block(block) .highlight_style(Style::default().add_modifier(Modifier::BOLD)) .highlight_symbol(CIRCLE); - f.render_stateful_widget(items, area, app_data.lock().get_container_state()); } } /// Draw the logs panel -pub fn logs( +pub fn logs( app_data: &Arc>, area: Rect, - f: &mut Frame<'_, B>, + f: &mut Frame, + fd: &FrameData, gui_state: &Arc>, - loading_icon: &str, ) { - let block = || generate_block(app_data, area, gui_state, SelectablePanel::Logs); - if gui_state.lock().status_contains(&[Status::Init]) { - let paragraph = Paragraph::new(format!("parsing logs {loading_icon}")) + let block = || generate_block(app_data, area, fd, gui_state, SelectablePanel::Logs); + if fd.init { + let paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon)) .style(Style::default()) .block(block()) .alignment(Alignment::Center); @@ -244,19 +249,18 @@ pub fn logs( } else { let items = List::new(logs) .block(block()) - .highlight_symbol(ARROW) + .highlight_symbol(RIGHT_ARROW) .highlight_style(Style::default().add_modifier(Modifier::BOLD)); - // This should always return Some, as logs is not empty - if let Some(i) = app_data.lock().get_log_state() { - f.render_stateful_widget(items, area, i); + if let Some(log_state) = app_data.lock().get_log_state() { + f.render_stateful_widget(items, area, log_state); } } } } /// Draw the cpu + mem charts -pub fn chart(f: &mut Frame<'_, B>, area: Rect, app_data: &Arc>) { +pub fn chart(f: &mut Frame, area: Rect, app_data: &Arc>) { if let Some((cpu, mem)) = app_data.lock().get_chart_data() { let area = Layout::default() .direction(Direction::Horizontal) @@ -275,6 +279,7 @@ pub fn chart(f: &mut Frame<'_, B>, area: Rect, app_data: &Arc( /// Draw heading bar at top of program, always visible /// TODO Should separate into loading icon/headers/help functions #[allow(clippy::too_many_lines)] -pub fn heading_bar( +pub fn heading_bar( area: Rect, - columns: &Columns, - f: &mut Frame<'_, B>, - has_containers: bool, - loading_icon: &str, - sorted_by: Option<(Header, SortedOrder)>, + frame: &mut Frame, + data: &FrameData, gui_state: &Arc>, ) { let block = |fg: Color| Block::default().style(Style::default().bg(Color::Magenta).fg(fg)); - let help_visible = gui_state.lock().status_contains(&[Status::Help]); - f.render_widget(block(Color::Black), area); + frame.render_widget(block(Color::Black), area); // Generate a block for the header, if the header is currently being used to sort a column, then highlight it white let header_block = |x: &Header| { let mut color = Color::Black; let mut suffix = ""; let mut suffix_margin = 0; - if let Some((a, b)) = sorted_by.as_ref() { + if let Some((a, b)) = data.sorted_by.as_ref() { if x == a { match b { - SortedOrder::Asc => suffix = " ⌃", - SortedOrder::Desc => suffix = " ⌄", + SortedOrder::Asc => suffix = " ▲", + SortedOrder::Desc => suffix = " ▼", } suffix_margin = 2; color = Color::White; @@ -408,15 +409,15 @@ pub fn heading_bar( // Meta data to iterate over to create blocks with correct widths let header_meta = [ - (Header::State, columns.state.1), - (Header::Status, columns.status.1), - (Header::Cpu, columns.cpu.1), - (Header::Memory, columns.mem.1 + columns.mem.2 + 3), - (Header::Id, columns.id.1), - (Header::Name, columns.name.1), - (Header::Image, columns.image.1), - (Header::Rx, columns.net_rx.1), - (Header::Tx, columns.net_tx.1), + (Header::State, data.columns.state.1), + (Header::Status, data.columns.status.1), + (Header::Cpu, data.columns.cpu.1), + (Header::Memory, data.columns.mem.1 + data.columns.mem.2 + 3), + (Header::Id, data.columns.id.1), + (Header::Name, data.columns.name.1), + (Header::Image, data.columns.image.1), + (Header::Rx, data.columns.net_rx.1), + (Header::Tx, data.columns.net_tx.1), ]; let header_data = header_meta @@ -427,13 +428,13 @@ pub fn heading_bar( }) .collect::>(); - let suffix = if help_visible { "exit" } else { "show" }; + let suffix = if data.help_visible { "exit" } else { "show" }; let info_text = format!("( h ) {suffix} help {MARGIN}",); let info_width = info_text.chars().count(); let column_width = usize::from(area.width).saturating_sub(info_width); let column_width = if column_width > 0 { column_width } else { 1 }; - let splits = if has_containers { + let splits = if data.has_containers { vec![ Constraint::Min(2), Constraint::Min(column_width.try_into().unwrap_or_default()), @@ -445,20 +446,19 @@ pub fn heading_bar( let split_bar = Layout::default() .direction(Direction::Horizontal) - .constraints(splits.as_ref()) + .constraints(splits) .split(area); - if has_containers { + if data.has_containers { // Draw loading icon, or not, and a prefix with a single space - let loading_icon = format!("{loading_icon:>2}"); - let loading_paragraph = Paragraph::new(loading_icon) + let loading_paragraph = Paragraph::new(format!("{:>2}", data.loading_icon)) .block(block(Color::White)) .alignment(Alignment::Center); - f.render_widget(loading_paragraph, split_bar[0]); + frame.render_widget(loading_paragraph, split_bar[0]); let container_splits = header_data.iter().map(|i| i.2).collect::>(); let headers_section = Layout::default() .direction(Direction::Horizontal) - .constraints(container_splits.as_ref()) + .constraints(container_splits) .split(split_bar[1]); // draw the actual header blocks @@ -467,12 +467,12 @@ pub fn heading_bar( gui_state .lock() .update_region_map(Region::Header(header), rect); - f.render_widget(paragraph, rect); + frame.render_widget(paragraph, rect); } } // show/hide help - let color = if help_visible { + let color = if data.help_visible { Color::Black } else { Color::White @@ -482,8 +482,8 @@ pub fn heading_bar( .alignment(Alignment::Right); // If no containers, don't display the headers, could maybe do this first? - let help_index = if has_containers { 2 } else { 0 }; - f.render_widget(help_paragraph, split_bar[help_index]); + let help_index = if data.has_containers { 2 } else { 0 }; + frame.render_widget(help_paragraph, split_bar[help_index]); } /// Help popup box needs these three pieces of information @@ -569,7 +569,7 @@ impl HelpInfo { button_item("tab"), or(), button_item("shift+tab"), - button_desc("to change panels"), + button_desc("change panels"), ]), Line::from(vec![ space(), @@ -580,35 +580,45 @@ impl HelpInfo { button_item("PgUp PgDown"), or(), button_item("Home End"), - button_desc("to change selected line"), + button_desc("change selected line"), ]), Line::from(vec![ space(), button_item("enter"), - button_desc("to send docker container command"), + button_desc("send docker container command"), + ]), + Line::from(vec![ + space(), + button_item("e"), + button_desc("exec into a container"), ]), Line::from(vec![ space(), button_item("h"), - button_desc("to toggle this help information"), + button_desc("toggle this help information"), ]), - Line::from(vec![space(), button_item("0"), button_desc("to stop sort")]), + Line::from(vec![ + space(), + button_item("s"), + button_desc("save logs to file"), + ]), + Line::from(vec![ + space(), + button_item("m"), + button_desc( + "toggle mouse capture - if disabled, text on screen can be selected & copied", + ), + ]), + Line::from(vec![space(), button_item("0"), button_desc("stop sort")]), Line::from(vec![ space(), button_item("1 - 9"), button_desc("sort by header - or click header"), ]), - Line::from(vec![ - space(), - button_item("m"), - button_desc( - "to toggle mouse capture - if disabled, text on screen can be selected & copied", - ), - ]), Line::from(vec![ space(), button_item("q"), - button_desc("to quit at any time"), + button_desc("quit at any time"), ]), ]; @@ -646,7 +656,7 @@ impl HelpInfo { } /// Draw the help box in the centre of the screen -pub fn help_box(f: &mut Frame<'_, B>) { +pub fn help_box(f: &mut Frame) { let title = format!(" {VERSION} "); let name_info = HelpInfo::gen_name(); @@ -725,11 +735,7 @@ pub fn help_box(f: &mut Frame<'_, B>) { /// Draw the delete confirm box in the centre of the screen /// take in container id and container name here? -pub fn delete_confirm( - f: &mut Frame<'_, B>, - gui_state: &Arc>, - name: &str, -) { +pub fn delete_confirm(f: &mut Frame, gui_state: &Arc>, name: &str) { let block = Block::default() .title(" Confirm Delete ") .border_type(BorderType::Rounded) @@ -834,7 +840,7 @@ pub fn delete_confirm( } /// Draw an error popup over whole screen -pub fn error(f: &mut Frame<'_, B>, error: AppError, seconds: Option) { +pub fn error(f: &mut Frame, error: AppError, seconds: Option) { let block = Block::default() .title(" Error ") .border_type(BorderType::Rounded) @@ -850,7 +856,7 @@ pub fn error(f: &mut Frame<'_, B>, error: AppError, seconds: Option< seconds.unwrap_or(5) ) } - _ => String::from("\n\n ( c ) to clear error\n ( q ) to quit oxker"), + _ => String::from("\n\n ( c ) clear error\n ( q ) quit oxker "), }; let mut text = format!("\n{error}"); @@ -876,13 +882,13 @@ pub fn error(f: &mut Frame<'_, B>, error: AppError, seconds: Option< } /// Draw info box in one of the 9 BoxLocations -pub fn info(f: &mut Frame<'_, B>, text: String) { +pub fn info(f: &mut Frame, text: &str, instant: Instant, gui_state: &Arc>) { let block = Block::default() .title("") .title_alignment(Alignment::Center) .borders(Borders::NONE); - let mut max_line_width = max_line_width(&text); + let mut max_line_width = max_line_width(text); let mut lines = text.lines().count(); // Add some horizontal & vertical margins @@ -897,6 +903,9 @@ pub fn info(f: &mut Frame<'_, B>, text: String) { let area = popup(lines, max_line_width, f.size(), BoxLocation::BottomRight); f.render_widget(Clear, area); f.render_widget(paragraph, area); + if instant.elapsed().as_millis() > 4000 { + gui_state.lock().reset_info_box(); + } } /// draw a box in the one of the BoxLocations, based on max line width + number of lines @@ -927,8 +936,18 @@ fn popup(text_lines: usize, text_width: usize, r: Rect, box_location: BoxLocatio .split(popup_layout[indexes.0])[indexes.1] } +#[cfg(debug_assertions)] +// Single row at the top of the screen for debugging +pub fn debug_bar(area: Rect, f: &mut Frame, debug_string: &str) { + let block = Block::default().style(Style::default().bg(Color::Red)); + let paragraph = Paragraph::new(debug_string) + .style(Style::default().fg(Color::White)) + .block(block); + f.render_widget(paragraph, area); +} + // Draw nothing, as in a blank screen -// pub fn nothing(f: &mut Frame<'_, B>) { +// pub fn nothing(f: &mut Frame) { // let whole_layout = Layout::default() // .direction(Direction::Vertical) // .constraints([Constraint::Min(100)].as_ref()) diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index 18e92f5..fa2d229 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -1,8 +1,17 @@ +use parking_lot::Mutex; use ratatui::layout::{Constraint, Rect}; -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Instant, +}; +use tokio::task::JoinHandle; use uuid::Uuid; -use crate::app_data::{ContainerId, Header}; +use crate::{ + app_data::{ContainerId, Header}, + exec::ExecMode, +}; #[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] pub enum SelectablePanel { @@ -150,25 +159,28 @@ const FRAMES_LEN: u8 = 9; /// Various functions (e.g input handler), operate differently depending upon current Status #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum Status { - Init, - Help, - DockerConnect, DeleteConfirm, + DockerConnect, Error, + Exec, + Help, + Init, + Logs, } /// Global gui_state, stored in an Arc #[derive(Debug, Default, Clone)] pub struct GuiState { + delete_container: Option, + delete_map: HashMap, heading_map: HashMap, is_loading: HashSet, loading_index: u8, panel_map: HashMap, - delete_map: HashMap, + selected_panel: SelectablePanel, status: HashSet, - delete_container: Option, - pub info_box_text: Option, - pub selected_panel: SelectablePanel, + exec_mode: Option, + pub info_box_text: Option<(String, Instant)>, } impl GuiState { /// Clear panels hash map, so on resize can fix the sizes for mouse clicks @@ -176,6 +188,11 @@ impl GuiState { self.panel_map.clear(); } + /// Get the currently selected panel + pub const fn get_selected_panel(&self) -> SelectablePanel { + self.selected_panel + } + /// Check if a given Rect (a clicked area of 1x1), interacts with any known panels pub fn panel_intersect(&mut self, rect: Rect) { if let Some(data) = self @@ -254,16 +271,41 @@ impl GuiState { } /// Remove a gui_status into the current gui_status HashSet + /// Remove exec mode & deleteConfirm is required pub fn status_del(&mut self, status: Status) { self.status.remove(&status); - if status == Status::DeleteConfirm { - self.status.remove(&Status::DeleteConfirm); + match status { + Status::DeleteConfirm => { + self.status.remove(&Status::DeleteConfirm); + } + Status::Exec => { + self.exec_mode = None; + } + _ => (), } } + /// Inset the ExecMode into self, and set the Status as exec + /// Using StatusPush with Status::Exec won't insert into the hash map + /// To force self.exec_mode to be set + pub fn set_exec_mode(&mut self, mode: ExecMode) { + self.exec_mode = Some(mode); + self.status.insert(Status::Exec); + } + + pub fn get_exec_mode(&mut self) -> Option { + self.exec_mode.clone() + } + /// Insert a gui_status into the current gui_status HashSet + /// If the status is Exec, it won't get inserted, set_exec_mode() should be used instead pub fn status_push(&mut self, status: Status) { - self.status.insert(status); + match status { + Status::Exec => (), + _ => { + self.status.insert(status); + } + } } /// Change to next selectable panel @@ -287,7 +329,7 @@ impl GuiState { } /// If is_loading has any entries, return the char at FRAMES[index], else an empty char, which needs to take up the same space, hence ' ' - pub fn get_loading(&mut self) -> char { + pub fn get_loading(&self) -> char { if self.is_loading.is_empty() { ' ' } else { @@ -296,16 +338,37 @@ impl GuiState { } /// Remove a loading_uuid from the is_loading HashSet, if empty, reset loading_index to 0 - pub fn remove_loading(&mut self, uuid: Uuid) { + fn remove_loading(&mut self, uuid: Uuid) { self.is_loading.remove(&uuid); if self.is_loading.is_empty() { self.loading_index = 0; } } + /// Animate the loading icon in its own Tokio thread + pub fn start_loading_animation( + gui_state: &Arc>, + loading_uuid: Uuid, + ) -> JoinHandle<()> { + gui_state.lock().next_loading(loading_uuid); + let gui_state = Arc::clone(gui_state); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + gui_state.lock().next_loading(loading_uuid); + } + }) + } + + /// Stop the loading_spin function, and reset gui loading status + pub fn stop_loading_animation(&mut self, handle: &JoinHandle<()>, loading_uuid: Uuid) { + handle.abort(); + self.remove_loading(loading_uuid); + } + /// Set info box content pub fn set_info_box(&mut self, text: &str) { - self.info_box_text = Some(text.to_owned()); + self.info_box_text = Some((text.to_owned(), std::time::Instant::now())); } /// Remove info box content diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c8b8da6..2df6c51 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,9 +4,9 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use parking_lot::Mutex; +use parking_lot::{Mutex, MutexGuard}; use ratatui::{ - backend::{Backend, CrosstermBackend}, + backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, Frame, Terminal, }; @@ -26,19 +26,21 @@ mod gui_state; pub use self::color_match::*; pub use self::gui_state::{DeleteButton, GuiState, SelectablePanel, Status}; use crate::{ - app_data::AppData, app_error::AppError, docker_data::DockerMessage, + app_data::{AppData, Columns, ContainerId, Header, SortedOrder}, + app_error::AppError, + exec::TerminalSize, input_handler::InputMessages, }; pub struct Ui { app_data: Arc>, - docker_sx: Sender, gui_state: Arc>, input_poll_rate: Duration, + input_tx: Sender, is_running: Arc, now: Instant, - sender: Sender, terminal: Terminal>, + cursor_position: (u16, u16), } impl Ui { @@ -57,20 +59,21 @@ impl Ui { /// Create a new Ui struct, and execute the drawing loop pub async fn create( app_data: Arc>, - docker_sx: Sender, gui_state: Arc>, + input_tx: Sender, is_running: Arc, - sender: Sender, ) { - if let Ok(terminal) = Self::setup_terminal() { + if let Ok(mut terminal) = Self::setup_terminal() { + // let args = app_data.lock().args.clone(); + let cursor_position = terminal.get_cursor().unwrap_or_default(); let mut ui = Self { app_data, - docker_sx, + cursor_position, gui_state, input_poll_rate: std::time::Duration::from_millis(100), + input_tx, is_running, now: Instant::now(), - sender, terminal, }; if let Err(e) = ui.draw_ui().await { @@ -86,19 +89,17 @@ impl Ui { /// Setup the terminal for full-screen drawing mode, with mouse capture fn setup_terminal() -> Result>> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - Self::enable_mouse_capture()?; + let stdout = Self::init_terminal()?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } - /// This is a fix for mouse-events being printed to screen, read an event and do nothing with it - fn nullify_event_read(&self) { - if crossterm::event::poll(self.input_poll_rate).unwrap_or(true) { - event::read().ok(); - } + fn init_terminal() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + Self::enable_mouse_capture()?; + Ok(stdout) } /// reset the terminal back to default settings @@ -111,6 +112,9 @@ impl Ui { DisableMouseCapture )?; disable_raw_mode()?; + self.terminal.clear().ok(); + self.terminal + .set_cursor(self.cursor_position.0, self.cursor_position.1)?; Ok(self.terminal.show_cursor()?) } @@ -137,12 +141,33 @@ impl Ui { Ok(()) } + /// Use exeternal docker cli to exec into a container + async fn exec(&mut self) { + let exec_mode = self.gui_state.lock().get_exec_mode(); + + if let Some(mode) = exec_mode { + self.reset_terminal().ok(); + self.terminal.clear().ok(); + if let Err(e) = mode.run(TerminalSize::new(&self.terminal)).await { + self.app_data + .lock() + .set_error(e, &self.gui_state, Status::Error); + }; + } + self.terminal.clear().ok(); + self.reset_terminal().ok(); + Self::init_terminal().ok(); + self.gui_state.lock().status_del(Status::Exec); + } + /// The loop for drawing the main UI to the terminal async fn gui_loop(&mut self) -> Result<(), AppError> { - let update_duration = - std::time::Duration::from_millis(u64::from(self.app_data.lock().args.docker_interval)); - while self.is_running.load(Ordering::SeqCst) { + let exec = self.gui_state.lock().status_contains(&[Status::Exec]); + if exec { + self.exec().await; + } + if self .terminal .draw(|frame| draw_frame(frame, &self.app_data, &self.gui_state)) @@ -150,10 +175,11 @@ impl Ui { { return Err(AppError::Terminal); } + if crossterm::event::poll(self.input_poll_rate).unwrap_or(false) { if let Ok(event) = event::read() { if let Event::Key(key) = event { - self.sender + self.input_tx .send(InputMessages::ButtonPress((key.code, key.modifiers))) .await .ok(); @@ -162,7 +188,7 @@ impl Ui { event::MouseEventKind::Down(_) | event::MouseEventKind::ScrollDown | event::MouseEventKind::ScrollUp => { - self.sender.send(InputMessages::MouseEvent(m)).await.ok(); + self.input_tx.send(InputMessages::MouseEvent(m)).await.ok(); } _ => (), } @@ -172,11 +198,6 @@ impl Ui { } } } - - if self.now.elapsed() >= update_duration { - self.docker_sx.send(DockerMessage::Update).await.ok(); - self.now = Instant::now(); - } } Ok(()) } @@ -192,57 +213,89 @@ impl Ui { } else { self.gui_loop().await?; } - self.nullify_event_read(); Ok(()) } } -#[macro_export] -/// This macro simplifies the definition and evaluation of variables by capturing and immediately evaluating an expression. -macro_rules! value_capture { - ($name:ident, $lock_expr:expr) => { - let $name = || $lock_expr; - let $name = $name(); - }; +#[cfg(not(debug_assertions))] +fn get_wholelayout(f: &Frame) -> std::rc::Rc<[ratatui::layout::Rect]> { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Min(100)].as_ref()) + .split(f.size()) +} + +#[cfg(debug_assertions)] +fn get_wholelayout(f: &Frame) -> std::rc::Rc<[ratatui::layout::Rect]> { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Min(1), Constraint::Min(100)].as_ref()) + .split(f.size()) +} + +/// Frequent data required by multiple framde drawing functions, can reduce mutex reads by placing it all in here +#[derive(Debug)] +pub struct FrameData { + columns: Columns, + delete_confirm: Option, + has_containers: bool, + has_error: Option, + height: u16, + help_visible: bool, + init: bool, + info_text: Option<(String, Instant)>, + loading_icon: String, + selected_panel: SelectablePanel, + sorted_by: Option<(Header, SortedOrder)>, +} + +impl From<(MutexGuard<'_, AppData>, MutexGuard<'_, GuiState>)> for FrameData { + fn from(data: (MutexGuard<'_, AppData>, MutexGuard<'_, GuiState>)) -> Self { + // set max height for container section, needs +5 to deal with docker commands list and borders + let height = data.0.get_container_len(); + let height = if height < 12 { + u16::try_from(height + 5).unwrap_or_default() + } else { + 12 + }; + + Self { + columns: data.0.get_width(), + delete_confirm: data.1.get_delete_container(), + has_containers: data.0.get_container_len() > 1, + has_error: data.0.get_error(), + height, + help_visible: data.1.status_contains(&[Status::Help]), + init: data.1.status_contains(&[Status::Init]), + info_text: data.1.info_box_text.clone(), + loading_icon: data.1.get_loading().to_string(), + selected_panel: data.1.get_selected_panel(), + sorted_by: data.0.get_sorted(), + } + } } /// Draw the main ui to a frame of the terminal -/// TODO add a single line area for debug message - if not in release mode? -fn draw_frame( - f: &mut Frame<'_, B>, - app_data: &Arc>, - gui_state: &Arc>, -) { - value_capture!(height, app_data.lock().get_container_len()); - value_capture!(column_widths, app_data.lock().get_width()); - value_capture!(has_containers, app_data.lock().get_container_len() > 0); - value_capture!(sorted_by, app_data.lock().get_sorted()); - value_capture!(delete_confirm, gui_state.lock().get_delete_container()); - value_capture!(has_error, app_data.lock().get_error()); - value_capture!(info_text, gui_state.lock().info_box_text.clone()); - value_capture!(loading_icon, gui_state.lock().get_loading().to_string()); +fn draw_frame(f: &mut Frame, app_data: &Arc>, gui_state: &Arc>) { + let fd = FrameData::from((app_data.lock(), gui_state.lock())); - // set max height for container section, needs +5 to deal with docker commands list and borders - let height = if height < 12 { height + 5 } else { 12 }; + let whole_layout = get_wholelayout(f); + #[cfg(debug_assertions)] + draw_blocks::debug_bar(whole_layout[0], f, app_data.lock().get_debug_string()); - let whole_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Min(100)].as_ref()) - .split(f.size()); + #[cfg(debug_assertions)] + let whole_layout_split = (1, 2); + + #[cfg(not(debug_assertions))] + let whole_layout_split = (0, 1); // Split into 3, containers+controls, logs, then graphs let upper_main = Layout::default() .direction(Direction::Vertical) - .constraints( - [ - Constraint::Max(height.try_into().unwrap_or_default()), - Constraint::Percentage(50), - ] - .as_ref(), - ) - .split(whole_layout[1]); + .constraints([Constraint::Max(fd.height), Constraint::Percentage(50)].as_ref()) + .split(whole_layout[whole_layout_split.1]); - let top_split = if has_containers { + let top_split = if fd.has_containers { vec![Constraint::Percentage(90), Constraint::Percentage(10)] } else { vec![Constraint::Percentage(100)] @@ -250,10 +303,10 @@ fn draw_frame( // Containers + docker commands let top_panel = Layout::default() .direction(Direction::Horizontal) - .constraints(top_split.as_ref()) + .constraints(top_split) .split(upper_main[0]); - let lower_split = if has_containers { + let lower_split = if fd.has_containers { vec![Constraint::Percentage(75), Constraint::Percentage(25)] } else { vec![Constraint::Percentage(100)] @@ -262,25 +315,17 @@ fn draw_frame( // Split into 2, logs, and optional charts let lower_main = Layout::default() .direction(Direction::Vertical) - .constraints(lower_split.as_ref()) + .constraints(lower_split) .split(upper_main[1]); - draw_blocks::containers(app_data, top_panel[0], f, gui_state, &column_widths); + draw_blocks::containers(app_data, top_panel[0], f, &fd, gui_state, &fd.columns); - draw_blocks::logs(app_data, lower_main[0], f, gui_state, &loading_icon); + draw_blocks::logs(app_data, lower_main[0], f, &fd, gui_state); - draw_blocks::heading_bar( - whole_layout[0], - &column_widths, - f, - has_containers, - &loading_icon, - sorted_by, - gui_state, - ); + draw_blocks::heading_bar(whole_layout[whole_layout_split.0], f, &fd, gui_state); - if let Some(id) = delete_confirm { - app_data.lock().get_container_name_by_id(&id).map_or_else( + if let Some(id) = fd.delete_confirm.as_ref() { + app_data.lock().get_container_name_by_id(id).map_or_else( || { // If a container is deleted outside of oxker but whilst the Delete Confirm dialog is open, it can get caught in kind of a dead lock situation // so if in that unique situation, just clear the delete_container id @@ -293,21 +338,21 @@ fn draw_frame( } // only draw commands + charts if there are containers - if has_containers { - draw_blocks::commands(app_data, top_panel[1], f, gui_state); + if fd.has_containers { + draw_blocks::commands(app_data, top_panel[1], f, &fd, gui_state); draw_blocks::chart(f, lower_main[1], app_data); } - if let Some(info) = info_text { - draw_blocks::info(f, info); + if let Some((text, instant)) = fd.info_text { + draw_blocks::info(f, &text, instant, gui_state); } // Check if error, and show popup if so - if gui_state.lock().status_contains(&[Status::Help]) { + if fd.help_visible { draw_blocks::help_box(f); } - if let Some(error) = has_error { + if let Some(error) = fd.has_error { draw_blocks::error(f, error, None); } }