commit 5101f60aaaf6a8285464a8079e222dacd5ff075b Author: Jack Wills <32690432+mrjackwills@users.noreply.github.com> Date: Mon Apr 25 02:44:39 2022 +0000 init commit diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..755ba60 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/rust/.devcontainer/base.Dockerfile + +# [Choice] Debian OS version (use bullseye on local arm64/Apple Silicon): buster, bullseye +ARG VARIANT="buster" +FROM mcr.microsoft.com/vscode/devcontainers/rust:0-${VARIANT} + +RUN printf "alias cls='clear'\nalias ll='ls -l --human-readable --color=auto --group-directories-first --classify --time-style=long-iso -all'" >> /etc/bash.bashrc + +RUN apt-get update && apt-get -y install upx-ucl diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..ec4c2ab --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,58 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/rust +{ + "name": "Rust", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Use the VARIANT arg to pick a Debian OS version: buster, bullseye + // Use bullseye when on local on arm64/Apple Silicon. + "VARIANT": "bullseye" + } + }, + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined", + ], + + "mounts": [ + "source=/etc/timezone,target=/etc/timezone,type=bind,readonly", + "source=/ramdrive,target=/ramdrive,type=bind", + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,readonly", + "source=${localEnv:HOME}/.cargo/bin/cargo-watch,target=/usr/local/cargo/bin/cargo-watch,type=bind,readonly", + "source=${localEnv:HOME}/.cargo/bin/cross,target=/usr/local/cargo/bin/cross,type=bind,readonly", + ], + + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.executable": "/usr/bin/lldb", + // VS Code don't watch files under ./target + "files.watcherExclude": { + "**/target/**": true + }, + "rust-analyzer.checkOnSave.command": "clippy" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "vadimcn.vscode-lldb", + "mutantdino.resourcemonitor", + "matklad.rust-analyzer", + "tamasfe.even-better-toml", + "serayuzgur.crates", + "christian-kohler.path-intellisense", + "timonwong.shellcheck", + "ms-vscode.live-server", + "rangav.vscode-thunder-client", + "bmuskalla.vscode-tldr" + ], + + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + "features": { + "docker-in-docker": "latest", + "git": "os-provided" + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8d49f8e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh eol=lf +*.md eol=CRLF +*.txt eol=lf + diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..57f1613 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. windows 11] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..82ee01a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: New Feature +about: Suggest an idea for this project +title: "[NEW FEATURE] " +labels: 'new feature' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..5a279bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,20 @@ +--- +name: Refactor +about: Refactor a component +title: "[REFACTOR] " +labels: 'refactor' +assignees: '' + +--- + +**Component to refactor.** +What component(s) needs to be refactored? + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..9f30c44 --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/.github/release-body.md b/.github/release-body.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/screenshot_01.jpg b/.github/screenshot_01.jpg new file mode 100644 index 0000000..fd08e93 Binary files /dev/null and b/.github/screenshot_01.jpg differ diff --git a/.github/workflows/create_release_and_build.yml b/.github/workflows/create_release_and_build.yml new file mode 100644 index 0000000..0d629ae --- /dev/null +++ b/.github/workflows/create_release_and_build.yml @@ -0,0 +1,88 @@ +name: Release CI +on: + push: + tags: + - 'v*.*.*' +jobs: + deploy: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@master + + # cache some rust data? + - uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Build for linux x86_64 + - name: build release linux_x86_64 + uses: actions-rs/cargo@v1 + with: + command: build + args: --release + - name: compress oxker_linux_x86_64 binary + run: tar -C target/release -czf ./oxker_linux_x86_64.tar.gz oxker + + # Build for linux aarch64, aka 64 bit pi 4 + - name: build release armv7 + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: aarch64-unknown-linux-musl + override: true + - uses: actions-rs/cargo@v1 + with: + use-cross: true + command: build + args: --target aarch64-unknown-linux-musl --release + - name: compress aarch64 binary + run: tar -C target/aarch64-unknown-linux-musl/release -czf ./oxker_linux_aarch64.tar.gz oxker + + # Build for linux armv6, aka 32 bit pi zero w + - name: build release armv6 + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: arm-unknown-linux-musleabihf + override: true + - uses: actions-rs/cargo@v1 + with: + use-cross: true + command: build + args: --target arm-unknown-linux-musleabihf --release + - name: compress armv6 binary + run: tar -C target/arm-unknown-linux-musleabihf/release -czf ./oxker_linux_armv6.tar.gz oxker + + # Build for windows + - name: build release windows_x86_64 + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: x86_64-pc-windows-gnu + override: true + - uses: actions-rs/cargo@v1 + with: + use-cross: true + command: build + args: --target x86_64-pc-windows-gnu --release + - name: compress windows_x86_64 binary + run: zip -j ./oxker_windows_x86_64.zip target/x86_64-pc-windows-gnu/release/oxker.exe + + - name: Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.ref }} + name: ${{ github.ref_name }} + body_path: ".github/release-body.md" + draft: false + files: | + oxker_linux_x86_64.tar.gz + oxker_linux_aarch64.tar.gz + oxker_linux_armv6.tar.gz + oxker_windows_x86_64.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cec0cd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/releases +Cargo.lock \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..382991c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ ++ init commit \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..59fce02 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "oxker" +version = "0.0.1" +edition = "2021" +authors = ["Jack Wills "] +description = "a simple tui to view & control docker containers" +repository = "https://github.com/mrjackwills/oxker" +license = "MIT" +readme = "README.md" + +[dependencies] +anyhow = "1.0" +bollard = "0.12.0" +cansi = "2.1.1" +clap={version="3.1.0", features = ["derive", "unicode"] } +crossterm = "0.23.2" +futures-util = "0.3.21" +parking_lot = {version= "0.12.0"} +tokio = {version = "1.17.0", features=["full"]} +tracing = "0.1.32" +tracing-subscriber = "0.3.9" +tui = "0.17" + +[dev-dependencies] + +[profile.release] +lto = true +codegen-units = 1 +panic = 'abort' +strip=true +debug = false + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..461de66 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Jack Wills + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e705f95 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +

+ +

+ +

+

oxker

+

+ +

+ A simple tui to view and control docker containers" +

+ +

+ Built in Rust, making heavy use of tui-rs & Bollard +

+ +

+ + + +

+ + +## Download + +See releases + + +## Run + +```./oxker``` + +available command line arguments +| argument|result| +|--|--| +|```-d [number > 0]```| set the update interval for docker information, is ms | +|```-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 | +|```-g```| No tui, basically a pointless debugging mode, for now | + +## Build step + +### x86_64 + +```cargo build --release``` + +### Raspberry pi + +requires docker & cross-rs + + +#### 64bit pi (pi 4, pi zero w 2) + +```cross build --target aarch64-unknown-linux-gnu --release``` + +#### 32bit pi (pi zero w) + +Tested, and fully working on pi zero w, running Raspberry Pi OS 32 bit, the initial logs parsing can take an extended period of time if thousands of lines long + +```cross build --target arm-unknown-linux-musleabihf --release``` + +If no memory information available, try appending ```/boot/cmdline.txt``` with + +```cgroup_enable=cpuset cgroup_enable=memory``` + +see https://forums.raspberrypi.com/viewtopic.php?t=203128 and https://github.com/docker/for-linux/issues/1112 + +### Compress executable + +compress output from \~3mb to ~1mb + +```upx --best --lzma target/release/oxker -o ./oxker``` + +### Untested on other platforms + +## Tests + +As of yet untested, needs work + +```cargo test -- --test-threads=1``` + +Run some example docker images + +```docker run --name redis -d redis:alpine3.15``` + +```docker run --name postgres -e POSTGRES_PASSWORD=never_use_this_password_in_production -d postgres:alpine``` + +```docker run -d --hostname my-rabbit --name rabbitmq rabbitmq:3``` + + diff --git a/create_release.sh b/create_release.sh new file mode 100755 index 0000000..594fcd7 --- /dev/null +++ b/create_release.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +# rust create_release +# v0.0.14 + +PACKAGE_NAME='oxker' +STAR_LINE='****************************************' +CWD=$(pwd) + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +PURPLE='\033[0;35m' +RESET='\033[0m' + + +# $1 string - error message +error_close() { + echo -e "\n${RED}ERROR - EXITED: ${YELLOW}$1${RESET}\n"; + exit 1 +} + + +if [ -z "$PACKAGE_NAME" ] +then + error_close "No package name" +fi + +# $1 string - question to ask +ask_yn () { + printf "%b%s? [y/N]:%b " "${GREEN}" "$1" "${RESET}" +} + +# return user input +user_input() { + read -r data + echo "$data" +} + +update_major () { + local bumped_major + bumped_major=$((MAJOR + 1)) + echo "${bumped_major}.0.0" +} + +update_minor () { + local bumped_minor + bumped_minor=$((MINOR + 1)) + echo "${MAJOR}.${bumped_minor}.0" +} + +update_patch () { + local bumped_patch + bumped_patch=$((PATCH + 1)) + echo "${MAJOR}.${MINOR}.${bumped_patch}" +} + +# Get the url of the github repo, strip .git from the end of it +get_git_remote_url() { + REMOTE_ORIGIN=$(git config --get remote.origin.url) + TO_REMOVE=".git" + GIT_REPO_URL="${REMOTE_ORIGIN//$TO_REMOVE}" +} + +# Check that git status is clean +check_git_clean() { + GIT_CLEAN=$(git status --porcelain) + if [[ -n $GIT_CLEAN ]] + then + error_close "git dirty" + fi +} + +# Check currently on dev branch +check_git() { + CURRENT_GIT_BRANCH=$(git branch --show-current) + check_git_clean + if [[ ! "$CURRENT_GIT_BRANCH" =~ ^dev$ ]] + then + error_close "not on dev branch" + fi +} + +# Ask user if current changelog is acceptable +ask_changelog_update() { + echo "${STAR_LINE}" + RELEASE_BODY_TEXT=$(sed '/# CHANGELOG.md for more details" > .github/release-body.md + echo -e "# ${NEW_TAG_VERSION}\n${DATE_SUBHEADING}${CHANGELOG_ADDITION}$(cat CHANGELOG.md)" > CHANGELOG.md + sed -i -E "s=(\s)\[([0-9a-f]{40})\](\n|\s|\,|\r)= [\2](${GIT_REPO_URL}/commit/\2),=g" ./CHANGELOG.md +} + +# update version in cargo.toml, to match selected current version/tag +update_cargo_toml () { + sed -i "s|^version = .*|version = \"${NEW_TAG_VERSION:1}\"|" Cargo.toml +} + +# Work out the current version, based on git tags +# create new semver version based on user input +check_tag () { + LATEST_TAG=$(git describe --tags --abbrev=0 --always) + echo -e "\nCurrent tag: ${PURPLE}${LATEST_TAG}${RESET}\n" + echo -e "${YELLOW}Choose new tag version:${RESET}\n" + if [[ $LATEST_TAG =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$ ]] + then + IFS="." read -r MAJOR MINOR PATCH <<< "${LATEST_TAG:1}" + else + MAJOR="0" + MINOR="0" + PATCH="0" + fi + MAJOR_TAG=v$(update_major) + MINOR_TAG=v$(update_minor) + PATCH_TAG=v$(update_patch) + OP_MAJOR="major___$MAJOR_TAG" + OP_MINOR="minor___$MINOR_TAG" + OP_PATCH="patch___$PATCH_TAG" + OPTIONS=("$OP_MAJOR" "$OP_MINOR" "$OP_PATCH") + select choice in "${OPTIONS[@]}" + do + case $choice in + "$OP_MAJOR" ) + NEW_TAG_VERSION="$MAJOR_TAG" + break;; + "$OP_MINOR") + NEW_TAG_VERSION="$MINOR_TAG" + break;; + "$OP_PATCH") + NEW_TAG_VERSION="$PATCH_TAG" + break;; + *) + error_close "invalid option $REPLY" + break;; + esac + done +} + +# ask continue, or quit +ask_continue () { + ask_yn "continue" + if [[ ! "$(user_input)" =~ ^y$ ]] + then + exit + fi +} + +# run all tests +cargo_test () { + cargo test -- --test-threads=1 + ask_continue +} + +# Build for linux, pi 32, pi 64, and windows +cargo_build_all() { + cargo build --release + cross build --target aarch64-unknown-linux-musl --release + cross build --target arm-unknown-linux-musleabihf --release + cross build --target x86_64-pc-windows-gnu --release + tar -C target/arm-unknown-linux-musleabihf/release -czf ./releases/oxker_linux_armv6.tar.gz oxker + tar -C target/aarch64-unknown-linux-musl/release -czf ./releases/oxker_linux_aarch64.tar.gz oxker + zip -j ./releases/oxker_windows_x86_64.zip target/x86_64-pc-windows-gnu/release/oxker.exe + tar -C target/release -czf ./releases/oxker_linux_x86_64.tar.gz oxker +} + +# Full flow to create a new release +release_flow() { + check_git + get_git_remote_url + cargo_test + cd "${CWD}" || error_close "Can't find ${CWD}" + check_tag + printf "\nnew tag chosen: %s\n\n" "${NEW_TAG_VERSION}" + RELEASE_BRANCH=release-$NEW_TAG_VERSION + echo -e + ask_changelog_update + git checkout -b "$RELEASE_BRANCH" + update_cargo_toml + git add . + git commit -m "chore: release $NEW_TAG_VERSION" + git checkout main + git merge --no-ff "$RELEASE_BRANCH" -m "chore: merge ${RELEASE_BRANCH} into main" + git tag -am "${RELEASE_BRANCH}" "$NEW_TAG_VERSION" + echo "git tag -am \"${RELEASE_BRANCH}\" \"$NEW_TAG_VERSION\"" + git push --atomic origin main "$NEW_TAG_VERSION" + git checkout dev + git merge --no-ff main -m 'chore: merge main into dev' + git branch -d "$RELEASE_BRANCH" +} + + +main() { + cmd=(dialog --backtitle "Choose build option" --radiolist "choose" 14 80 16) + options=( + 1 "fmt" off + 2 "build" off + 3 "test" off + 4 "release" off + ) + choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty) + exitStatus=$? + clear + if [ $exitStatus -ne 0 ]; then + exit + fi + for choice in $choices + do + case $choice in + 0) + exit + break;; + 1) + cargo fmt + main + break;; + 2) + cargo_build_all + main + break;; + 3) + npm_test + main + break;; + 4) + release_flow + break;; + esac + done +} + +main \ No newline at end of file diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs new file mode 100644 index 0000000..084fbcb --- /dev/null +++ b/src/app_data/container_state.rs @@ -0,0 +1,425 @@ +use std::{cmp::Ordering, collections::VecDeque, fmt}; + +use tui::{ + style::Color, + widgets::{ListItem, ListState}, +}; + +#[derive(Debug, Clone)] +pub struct StatefulList { + pub state: ListState, + pub items: Vec, +} + +impl StatefulList { + pub fn new(items: Vec) -> Self { + Self { + state: ListState::default(), + items, + } + } + pub fn end(&mut self) { + let len = self.items.len(); + if len > 0 { + self.state.select(Some(self.items.len() - 1)); + } + } + + pub fn start(&mut self) { + self.state.select(Some(0)); + } + + pub fn next(&mut self) { + if !self.items.is_empty() { + let i = match self.state.selected() { + Some(i) => { + if i < self.items.len() - 1 { + i + 1 + } else { + i + } + } + None => 0, + }; + self.state.select(Some(i)); + } + } + + pub fn previous(&mut self) { + if !self.items.is_empty() { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + } + + pub fn get_state_title(&self) -> String { + if self.items.is_empty() { + String::from("") + } else { + let len = self.items.len(); + let c = if let Some(value) = self.state.selected() { + if len > 0 { + value + 1 + } else { + value + } + } else { + 0 + }; + format!("{}/{}", c, self.items.len()) + } + } +} + +/// States of the container +#[derive(Clone, Debug, PartialEq, PartialOrd)] +pub enum State { + Dead, + Exited, + Paused, + Removing, + Restarting, + Running, + Unknown, +} + +impl State { + pub fn get_color(&self) -> Color { + match self { + Self::Running => Color::Green, + Self::Removing => Color::LightRed, + Self::Restarting => Color::LightGreen, + Self::Paused => Color::Yellow, + _ => Color::Red, + } + } +} + +impl From<&str> for State { + fn from(input: &str) -> Self { + match input { + "dead" => Self::Dead, + "exited" => Self::Exited, + "paused" => Self::Paused, + "removing" => Self::Removing, + "restarting" => Self::Restarting, + "running" => Self::Running, + _ => Self::Unknown, + } + } +} + +impl fmt::Display for State { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = match self { + Self::Dead => "✖ dead", + Self::Exited => "✖ exited", + Self::Paused => "॥ paused", + Self::Removing => "removing", + Self::Restarting => "↻ restarting", + Self::Running => "✓ running", + Self::Unknown => "? unknown", + }; + write!(f, "{}", disp) + } +} + +/// Items for the container control list +/// Should probably have a vec for each container +/// so that can remove Pause if container currently Paused etc +#[derive(Debug, Clone)] +pub enum DockerControls { + Pause, + Unpause, + Restart, + Stop, + Start, +} + +impl DockerControls { + pub fn get_color(&self) -> Color { + match self { + Self::Start => Color::Green, + Self::Stop => Color::Red, + Self::Restart => Color::Magenta, + Self::Pause => Color::Yellow, + Self::Unpause => Color::Blue, + } + } + + pub fn gen_vec(state: &State) -> Vec { + match state { + State::Dead | State::Exited => vec![Self::Start, Self::Restart], + State::Paused => vec![Self::Unpause, Self::Stop], + State::Running => vec![Self::Pause, Self::Restart, Self::Stop], + _ => vec![], + } + } +} + +impl fmt::Display for DockerControls { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = match self { + Self::Pause => "pause", + Self::Unpause => "unpause", + Self::Restart => "restart", + Self::Stop => "stop", + Self::Start => "start", + }; + write!(f, "{}", disp) + } +} + +pub trait Stats { + fn get_value(&self) -> f64; +} + +/// Struct for frequently updated CPU stats +/// So can use custom display formatter +/// Use trait Stats for use as generic in draw_chart function +#[derive(Clone, Debug)] +pub struct CpuStats { + value: f64, +} + +impl CpuStats { + pub fn new(value: f64) -> Self { + Self { value } + } +} + +impl Eq for CpuStats {} + +impl PartialEq for CpuStats { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl PartialOrd for CpuStats { + fn partial_cmp(&self, other: &Self) -> Option { + self.value.partial_cmp(&other.value) + } +} + +impl Ord for CpuStats { + fn cmp(&self, other: &Self) -> Ordering { + if self.value > other.value { + Ordering::Greater + } else if self.value == other.value { + Ordering::Equal + } else { + Ordering::Less + } + } +} + +impl Stats for CpuStats { + fn get_value(&self) -> f64 { + self.value + } +} + +impl fmt::Display for CpuStats { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = format!("{:05.2}%", self.value); + write!(f, "{:>x$}", disp, x = f.width().unwrap_or(1)) + } +} + +/// Struct for frequently updated memory usage stats +/// So can use custom display formatter +/// Use trait Stats for use as generic in draw_chart function +#[derive(Clone, Debug, Eq)] +pub struct ByteStats { + value: u64, +} + +impl PartialEq for ByteStats { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +impl PartialOrd for ByteStats { + fn partial_cmp(&self, other: &Self) -> Option { + self.value.partial_cmp(&other.value) + } +} + +impl Ord for ByteStats { + fn cmp(&self, other: &Self) -> Ordering { + self.value.cmp(&other.value) + } +} + +impl ByteStats { + pub fn new(value: u64) -> Self { + Self { value } + } + pub fn update(&mut self, value: u64) { + self.value = value; + } +} +impl Stats for ByteStats { + fn get_value(&self) -> f64 { + self.value as f64 + } +} + +// convert from bytes to kb, mb, gb etc +impl fmt::Display for ByteStats { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let one_kb = 1000.0; + let one_mb = one_kb * one_kb; + let one_gb = one_mb * 1000.0; + let as_f64 = self.value as f64; + let p = match as_f64 { + x if x >= one_gb => format!("{y:.2} GB", y = as_f64 / one_gb), + x if x >= one_kb => format!("{y:.2} MB", y = as_f64 / one_mb), + x if x >= one_mb => format!("{y:.2} kB", y = as_f64 / one_kb), + _ => format!("{} B", self.value), + }; + write!(f, "{:>x$}", p, x = f.width().unwrap_or(1)) + } +} + +/// Info for each container +#[derive(Debug, Clone)] +pub struct ContainerItem { + pub cpu_stats: VecDeque, + pub docker_controls: StatefulList, + pub id: String, + pub image: String, + pub last_updated: u64, + pub logs: StatefulList>, + pub mem_limit: ByteStats, + pub mem_stats: VecDeque, + pub name: String, + pub net_rx: ByteStats, + pub net_tx: ByteStats, + pub state: State, + pub status: String, +} + +pub type MemTuple = (Vec<(f64, f64)>, ByteStats, State); +pub type CpuTuple = (Vec<(f64, f64)>, CpuStats, State); + +impl ContainerItem { + /// Create a new container item + pub fn new(id: String, status: String, image: String, state: State, name: String) -> Self { + let mut docker_controls = StatefulList::new(DockerControls::gen_vec(&state)); + docker_controls.start(); + Self { + cpu_stats: VecDeque::with_capacity(60), + docker_controls, + id, + image, + last_updated: 0, + logs: StatefulList::new(vec![]), + mem_limit: ByteStats::new(0), + mem_stats: VecDeque::with_capacity(60), + name, + net_rx: ByteStats::new(0), + net_tx: ByteStats::new(0), + state, + status, + } + } + + /// Find the max value in the last 30 items in the cpu stats vec + fn max_cpu_stats(&self) -> CpuStats { + match self.cpu_stats.iter().max() { + Some(value) => value.to_owned(), + None => CpuStats::new(0.0), + } + } + + /// Find the max value in the last 30 items in the mem stats vec + fn max_mem_stats(&self) -> ByteStats { + match self.mem_stats.iter().max() { + Some(value) => value.to_owned(), + None => ByteStats::new(0), + } + } + + /// Convert cpu stats into a vec for the charts function + fn get_cpu_dataset(&self) -> Vec<(f64, f64)> { + self.cpu_stats + .iter() + .enumerate() + .map(|i| (i.0 as f64, i.1.value)) + .collect::>() + } + + /// Convert mem stats into a vec for the charts function + fn get_mem_dataset(&self) -> Vec<(f64, f64)> { + self.mem_stats + .iter() + .enumerate() + .map(|i| (i.0 as f64, i.1.value as f64)) + .collect::>() + } + + /// Get all cpu chart data + fn get_cpu_chart_data(&self) -> CpuTuple { + ( + self.get_cpu_dataset(), + self.max_cpu_stats(), + self.state.clone(), + ) + } + + /// Get all mem chart data + fn get_mem_chart_data(&self) -> MemTuple { + ( + self.get_mem_dataset(), + self.max_mem_stats(), + self.state.clone(), + ) + } + + /// Get chart info for cpu & memory in one function + /// So only need to call .lock() once + pub fn get_chart_data(&self) -> (CpuTuple, MemTuple) { + (self.get_cpu_chart_data(), self.get_mem_chart_data()) + } +} + +/// Container information panel headings + widths, for nice pretty formatting +#[derive(Debug)] +pub struct Columns { + pub cpu: (String, usize), + pub image: (String, usize), + pub name: (String, usize), + pub state: (String, usize), + pub status: (String, usize), + pub mem: (String, usize), + pub net_rx: (String, usize), + pub net_tx: (String, usize), +} + +impl Columns { + pub fn new() -> Self { + Self { + // 7 to allow for 100.00% + cpu: (String::from("cpu"), 7), + image: (String::from("image"), 5), + name: (String::from("name"), 4), + state: (String::from("state"), 11), + status: (String::from("status"), 16), + mem: (String::from("mem/limit"), 9), + net_rx: (String::from("↓ rx"), 5), + net_tx: (String::from("↑ tx"), 5), + } + } +} diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs new file mode 100644 index 0000000..f0094b1 --- /dev/null +++ b/src/app_data/mod.rs @@ -0,0 +1,397 @@ +use bollard::models::ContainerSummary; +use std::time::{SystemTime, UNIX_EPOCH}; +use tui::widgets::ListItem; + +mod container_state; + +use crate::{app_error::AppError, parse_args::CliArgs, ui::log_sanitizer}; +pub use container_state::*; + +/// Global app_state, stored in an Arc +#[derive(Debug)] +pub struct AppData { + args: CliArgs, + error: Option, + logs_parsed: bool, + pub containers: StatefulList, + pub init: bool, + pub show_error: bool, +} + +impl AppData { + /// Generate a default app_state + pub fn default(args: CliArgs) -> Self { + Self { + args, + containers: StatefulList::new(vec![]), + error: None, + init: false, + logs_parsed: false, + show_error: false, + } + } + + + // Current time as unix timestamp + fn get_systemtime(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("In our known reality, this error should never occur") + .as_secs() + } + + + /// Get the current select docker command + /// So know which command to execute + pub fn get_docker_command(&self) -> Option { + let mut output = None; + if let Some(index) = self.containers.state.selected() { + if let Some(control_index) = self.containers.items[index] + .docker_controls + .state + .selected() + { + output = + Some(self.containers.items[index].docker_controls.items[control_index].clone()) + } + } + output + } + + /// Change selected choice of docker commands of selected container + pub fn docker_command_next(&mut self) { + if let Some(index) = self.containers.state.selected() { + self.containers.items[index].docker_controls.next() + } + } + + /// Change selected choice of docker commands of selected container + pub fn docker_command_previous(&mut self) { + if let Some(index) = self.containers.state.selected() { + self.containers.items[index].docker_controls.previous() + } + } + + /// Change selected choice of docker commands of selected container + pub fn docker_command_start(&mut self) { + if let Some(index) = self.containers.state.selected() { + self.containers.items[index].docker_controls.start() + } + } + + /// Change selected choice of docker commands of selected container + pub fn docker_command_end(&mut self) { + if let Some(index) = self.containers.state.selected() { + self.containers.items[index].docker_controls.end() + } + } + + /// return single app_state error + pub fn get_error(&self) -> Option { + self.error.clone() + } + + /// remove single app_state error + pub fn remove_error(&mut self) { + self.error = None; + } + + /// insert single app_state error + pub fn set_error(&mut self, error: AppError) { + self.error = Some(error); + } + + /// Find the if of the currently selected container + /// If any containers on system, will always return + /// Only returns None when no containers found + pub fn get_selected_container_id(&self) -> Option { + let mut output = None; + if let Some(index) = self.containers.state.selected() { + let id = self + .containers + .items + .iter() + .skip(index) + .take(1) + .map(|i| i.id.to_owned()) + .collect::(); + output = Some(id) + } + output + } + + /// Find the index of the currently selected single log line + pub fn get_selected_log_index(&self) -> Option { + let mut output = None; + if let Some(id) = self.get_selected_container_id() { + if let Some(index) = self.containers.items.iter().position(|i| i.id == id) { + output = Some(index); + } + } + output + } + + /// Get the title for log panel for selected container + /// will be "logs x/x" + pub fn get_log_title(&self) -> String { + if let Some(index) = self.get_selected_log_index() { + self.containers.items[index].logs.get_state_title() + } else { + String::from("") + } + } + + /// select next selected log line + pub fn log_next(&mut self) { + if let Some(index) = self.get_selected_log_index() { + self.containers.items[index].logs.next() + } + } + + /// select previous selected log line + pub fn log_previous(&mut self) { + if let Some(index) = self.get_selected_log_index() { + self.containers.items[index].logs.previous() + } + } + + /// select last selected log line + pub fn log_end(&mut self) { + if let Some(index) = self.get_selected_log_index() { + self.containers.items[index].logs.end() + } + } + + /// select first selected log line + pub fn log_start(&mut self) { + if let Some(index) = self.get_selected_log_index() { + self.containers.items[index].logs.start() + } + } + + pub fn initialised(&mut self, all_ids: &[(bool, String)]) -> 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(); + self.logs_parsed && count_is_running == number_with_cpu_status + } + + /// Just get the total number of containers + pub fn get_container_len(&self) -> usize { + self.containers.items.len() + } + + /// Find the widths for the strings in the containers panel + /// So can display nicely and evenly + pub fn get_width(&self) -> Columns { + let mut output = Columns::new(); + let count = |x: &String| x.chars().count(); + + for container in self.containers.items.iter() { + let cpu_count = count( + &container + .cpu_stats + .back() + .unwrap_or(&CpuStats::new(0.0)) + .to_string(), + ); + let mem_count = count(&format!( + "{} / {}", + container.mem_stats.back().unwrap_or(&ByteStats::new(0)), + container.mem_limit + )); + + let net_rx_count = count(&container.net_rx.to_string()); + let net_tx_count = count(&container.net_tx.to_string()); + let image_count = count(&container.image); + let name_count = count(&container.name); + let state_count = count(&container.state.to_string()); + let status_count = count(&container.status); + + if cpu_count > output.cpu.1 { + output.cpu.1 = cpu_count; + }; + if image_count > output.image.1 { + output.image.1 = image_count; + }; + if mem_count > output.mem.1 { + output.mem.1 = mem_count; + }; + if name_count > output.name.1 { + output.name.1 = name_count; + }; + if state_count > output.state.1 { + output.state.1 = state_count; + }; + if status_count > output.status.1 { + output.status.1 = status_count; + }; + + if net_rx_count > output.net_rx.1 { + output.net_rx.1 = net_rx_count; + }; + + if net_tx_count > output.net_tx.1 { + output.net_tx.1 = net_tx_count; + }; + } + output + } + + /// Get all containers ids + pub fn get_all_ids(&self) -> Vec { + self.containers + .items + .iter() + .map(|i| i.id.to_owned()) + .collect::>() + } + + /// find container given id + fn get_container_by_id(&mut self, id: &str) -> Option<&mut ContainerItem> { + self.containers.items.iter_mut().find(|i| i.id == id) + } + + /// Update container mem + cpu stats, in single function so only need to call .lock() once + pub fn update_stats( + &mut self, + id: String, + cpu_stat: Option, + mem_stat: Option, + mem_limit: u64, + rx: u64, + tx: u64, + ) { + if let Some(container) = self.get_container_by_id(&id) { + if container.cpu_stats.len() >= 60 { + container.cpu_stats.pop_front(); + } + if container.mem_stats.len() >= 60 { + container.mem_stats.pop_front(); + } + + if let Some(cpu) = cpu_stat { + container.cpu_stats.push_back(CpuStats::new(cpu)); + } + if let Some(mem) = mem_stat { + container.mem_stats.push_back(ByteStats::new(mem)); + } + + container.net_rx.update(rx); + container.net_tx.update(tx); + container.mem_limit.update(mem_limit); + } + } + + /// Update, or insert, containers + pub fn update_containers(&mut self, containers: &[ContainerSummary]) { + let all_ids = self.get_all_ids(); + + if !containers.is_empty() && self.containers.state.selected().is_none() { + self.containers.start(); + } + + for (index, id) in all_ids.iter().enumerate() { + if !containers + .iter() + .map(|i| i.id.as_ref().unwrap()) + .any(|x| x == id) + { + // If removed container is currently selected, then change selected to previous + // This will default to 0 in any edge cases + if self.containers.state.selected().is_some() { + self.containers.previous(); + } + self.containers.items.remove(index); + } + } + + for i in containers.iter() { + let id = i.id.as_ref().unwrap().to_owned(); + let mut name = i + .names + .as_ref() + .unwrap_or(&vec!["".to_owned()]) + .get(0) + .unwrap() + .to_owned(); + if let Some(c) = name.chars().next() { + if c == '/' { + name.remove(0); + } + } + + let state = State::from(i.state.as_ref().unwrap_or(&"dead".to_owned()).trim()); + let status = i + .status + .as_ref() + .unwrap_or(&"".to_owned()) + .trim() + .to_owned(); + let image = i.image.as_ref().unwrap_or(&"".to_owned()).trim().to_owned(); + if let Some(current_container) = self.get_container_by_id(&id) { + if current_container.name != name { + current_container.name = name + }; + if current_container.status != status { + current_container.status = status + }; + if current_container.state != state { + current_container.docker_controls.items = DockerControls::gen_vec(&state); + + // Update the list state, needs to be None if the gen_vec returns an empty vec + match state { + State::Removing | State::Restarting | State::Unknown => { + current_container.docker_controls.state.select(None) + } + _ => current_container.docker_controls.start(), + }; + current_container.state = state; + }; + if current_container.image != image { + current_container.image = image + }; + } else { + let mut container = ContainerItem::new(id, status, image, state, name); + container.logs.end(); + self.containers.items.push(container); + } + } + } + + /// update logs of a given container, based on index not id + pub fn update_log_by_index(&mut self, output: Vec, index: usize) { + let tz = self.get_systemtime(); + if let Some(container) = self.containers.items.get_mut(index) { + container.last_updated = tz; + let current_len = container.logs.items.len(); + output.iter().for_each(|i| { + let lines = if self.args.color { + log_sanitizer::colorize_logs(i.to_owned()) + } else if self.args.raw { + log_sanitizer::raw(i.to_owned()) + } else { + log_sanitizer::remove_ansi(i.to_owned()) + }; + container.logs.items.push(ListItem::new(lines)); + }); + if container.logs.state.selected().is_none() + || container.logs.state.selected().unwrap() + 1 == current_len + { + container.logs.end(); + } + } + self.logs_parsed = true; + } + + + pub fn update_all_logs(&mut self, all_logs: Vec>) { + for (index, output) in all_logs.into_iter().enumerate() { + self.update_log_by_index(output, index); + } + } +} diff --git a/src/app_error.rs b/src/app_error.rs new file mode 100644 index 0000000..ed28d6f --- /dev/null +++ b/src/app_error.rs @@ -0,0 +1,45 @@ +use core::fmt; +use tracing::error; + +use crate::app_data::DockerControls; + +/// app errors to set in global state +#[allow(unused)] +#[derive(Debug, Clone)] +pub enum AppError { + DockerConnect, + DockerInterval, + InputPoll, + DockerCommand(DockerControls), + Terminal, +} + +impl AppError { + /// for handling errors from terminal + pub fn disp(&self) { + match self { + Self::DockerConnect => error!("Unable to access docker daemon"), + Self::DockerInterval => error!("Docker update interval needs to be greater than 0"), + Self::InputPoll => error!("Unable to poll user input"), + Self::Terminal => error!("Unable to draw to terminal"), + Self::DockerCommand(s) => { + let error = format!("Unable to {} container", s); + error!(%error); + } + } + } +} + +/// Convert errors into strings to display +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = match self { + Self::DockerConnect => "Unable to access docker daemon".to_owned(), + Self::DockerInterval => "Docker update interval needs to be greater than 0".to_owned(), + Self::InputPoll => "Unable to poll user input".to_owned(), + Self::Terminal => "Unable to draw to terminal".to_owned(), + Self::DockerCommand(s) => format!("Unable to {} container", s), + }; + write!(f, "{}", disp) + } +} diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs new file mode 100644 index 0000000..0ab2a94 --- /dev/null +++ b/src/docker_data/mod.rs @@ -0,0 +1,277 @@ +use bollard::{ + container::{ListContainersOptions, LogsOptions, Stats, StatsOptions}, + Docker, +}; +use futures_util::{future::join_all, StreamExt}; +use parking_lot::Mutex; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use crate::{app_data::AppData, parse_args::CliArgs, ui::GuiState}; + +pub struct DockerData { + app_data: Arc>, + docker: Arc, + gui_state: Arc>, + initialised: bool, + sleep_duration: Duration, + timestamps: bool, +} + +impl DockerData { + /// Use docker stats for work out current cpu usage + fn calculate_usage(stats: &Stats) -> f64 { + let mut cpu_percentage = 0.0; + let previous_cpu = stats.precpu_stats.cpu_usage.total_usage; + let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64 - previous_cpu as f64; + if stats.cpu_stats.system_cpu_usage.is_some() + && stats.precpu_stats.system_cpu_usage.is_some() + { + let system_delta = (stats.cpu_stats.system_cpu_usage.unwrap() + - stats.precpu_stats.system_cpu_usage.unwrap()) + as f64; + let online_cpus = stats.cpu_stats.online_cpus.unwrap_or_else(|| { + stats + .cpu_stats + .cpu_usage + .percpu_usage + .clone() + .unwrap_or_default() + .len() as u64 + }) as f64; + if system_delta > 0.0 && cpu_delta > 0.0 { + cpu_percentage = (cpu_delta / system_delta) * online_cpus * 100.0; + } + } + cpu_percentage + } + + /// Get a single docker stat in order to update mem and cpu usage + /// don't take &self, so that can tokio::spawn into it's on thread + async fn update_container_stat( + docker: Arc, + id: String, + app_data: Arc>, + is_running: bool, + ) { + let mut stream = docker + .stats( + &id, + Some(StatsOptions { + stream: false, + one_shot: !is_running, + }), + ) + .take(1); + + while let Some(Ok(stats)) = stream.next().await { + let mem_stat = stats.memory_stats.usage.unwrap_or(0); + let mem_limit = stats.memory_stats.limit.unwrap_or(0); + + let key = if let Some(networks) = &stats.networks { + networks.keys().next().map(|x| x.to_owned()) + } else { + None + }; + + let cpu_stats = Self::calculate_usage(&stats); + + let (rx, tx) = if let Some(k) = key { + let ii = stats.networks.unwrap(); + let v = ii.get(&k).unwrap(); + (v.rx_bytes.to_owned(), v.tx_bytes.to_owned()) + } else { + (0, 0) + }; + + if is_running { + app_data.lock().update_stats( + id.clone(), + Some(cpu_stats), + Some(mem_stat), + mem_limit, + rx, + tx, + ); + } else { + app_data + .lock() + .update_stats(id.clone(), None, None, mem_limit, rx, tx); + } + } + } + + /// Update all stats, spawn each container into own tokio::spawn thread + async fn update_all_container_stats(&mut self, all_ids: &[(bool, String)]) { + for (is_running, id) in all_ids.iter() { + let docker = Arc::clone(&self.docker); + let app_data = Arc::clone(&self.app_data); + let is_running = *is_running; + let id = id.to_owned(); + tokio::spawn(async move { + Self::update_container_stat(docker, id, app_data, is_running).await + }); + } + } + + /// 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 + pub async fn update_all_containers(&mut self) -> Vec<(bool, String)> { + let containers = self + .docker + .list_containers(Some(ListContainersOptions:: { + all: true, + ..Default::default() + })) + .await + .unwrap(); + + let mut output = vec![]; + // iter over containers, to only send ones which have an id, as use ID for extensivley! + // alternative is to create my own container struct, and will out with details + containers.iter().filter(|i| i.id.is_some()).for_each(|c| { + output.push(c.to_owned()); + }); + + self.app_data.lock().update_containers(&output); + output + .iter() + .map(|i| { + ( + i.state.as_ref().unwrap() == "running", + i.id.as_ref().unwrap().to_owned(), + ) + }) + .collect::>() + } + + /// Update single container logs + /// don't take &self, so that can tokio::spawn into it's on thread + async fn update_log( + docker: Arc, + id: String, + timestamps: bool, + since: i64, + ) -> Vec { + let options = Some(LogsOptions:: { + stdout: true, + timestamps, + since, + ..Default::default() + }); + + let mut logs = docker.logs(&id, options); + + let mut output = vec![]; + + while let Some(value) = logs.next().await { + if let Ok(data) = value { + let log_string = data.to_string(); + if !log_string.trim().is_empty() { + output.push(log_string); + } + } + } + output + } + + /// Update all logs, spawn each container into own tokio::spawn thread + // rename init all logs, as only gets run once + async fn update_all_logs(&mut self, all_ids: &[(bool, String)]) { + let mut handles = vec![]; + + for (_, id) in all_ids.iter() { + let docker = Arc::clone(&self.docker); + let timestamps = self.timestamps; + let id = id.to_owned(); + handles.push(Self::update_log(docker, id, timestamps, 0)); + } + let all_logs = join_all(handles).await; + self.app_data.lock().update_all_logs(all_logs); + } + + async fn update_everything(&mut self) { + let all_ids = self.update_all_containers().await; + let op_index = self.app_data.lock().get_selected_log_index(); + if let Some(index) = op_index { + let docker = Arc::clone(&self.docker); + let since = self.app_data.lock().containers.items[index].last_updated as i64; + let timestamps = self.timestamps; + let id = self.app_data.lock().containers.items[index].id.to_owned(); + let logs = Self::update_log(docker, id, timestamps, since).await; + self.app_data.lock().update_log_by_index(logs, index); + }; + + self.update_all_container_stats(&all_ids).await; + } + + /// Initialise self, and start the updated loop + pub async fn init( + args: CliArgs, + app_data: Arc>, + docker: Arc, + gui_state: Arc>, + ) { + if app_data.lock().get_error().is_none() { + let mut inner = Self { + app_data, + docker, + gui_state, + initialised: false, + sleep_duration: Duration::from_millis(args.docker as u64), + timestamps: args.timestamp, + }; + inner.initialise_container_data().await; + inner.update_loop().await; + } + } + + async fn initialise_container_data(&mut self) { + let gui_state = Arc::clone(&self.gui_state); + // could also just loop while init is false, would need to move an arc mutex into here + // so instead just abort at end of function + let loading_spin = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + gui_state.lock().next_loading(); + } + }); + + let all_ids = self.update_all_containers().await; + self.update_all_container_stats(&all_ids).await; + + // Maybe only do a single one at first? + self.update_all_logs(&all_ids).await; + + if all_ids.is_empty() { + self.initialised = true; + } + + // wait until all logs have initialised + while !self.initialised { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + self.initialised = self.app_data.lock().initialised(&all_ids); + } + self.app_data.lock().init = true; + loading_spin.abort(); + self.gui_state.lock().reset_loading(); + } + + /// Update all items, wait until all complete + /// sleep for CliArgs.docker ms before updating next + async fn update_loop(&mut self) { + loop { + let start = Instant::now(); + self.update_everything().await; + + let elapsed = start.elapsed(); + if elapsed < self.sleep_duration { + tokio::time::sleep(self.sleep_duration - elapsed).await; + } + } + } +} + +// tests, use redis-test container, check logs exists, and selector of logs, and that it increases, and matches end, when you run restart on the docker containers diff --git a/src/input_handler/message.rs b/src/input_handler/message.rs new file mode 100644 index 0000000..cbefb49 --- /dev/null +++ b/src/input_handler/message.rs @@ -0,0 +1,7 @@ +use crossterm::event::{KeyCode, MouseEvent}; + +#[derive(Debug, Clone)] +pub enum InputMessages { + ButtonPress(KeyCode), + MouseEvent(MouseEvent), +} diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs new file mode 100644 index 0000000..b41940a --- /dev/null +++ b/src/input_handler/mod.rs @@ -0,0 +1,262 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use bollard::{container::StartContainerOptions, Docker}; +use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind}; +use parking_lot::Mutex; +use tokio::sync::broadcast::Receiver; +use tui::layout::Rect; + +mod message; +use crate::{ + app_data::{AppData, DockerControls}, + app_error::AppError, + ui::{GuiState, SelectablePanel}, +}; +pub use message::InputMessages; + +/// Handle all input events +#[derive(Debug)] +pub struct InputHandler { + app_data: Arc>, + docker: Arc, + gui_state: Arc>, + is_running: Arc, + rec: Receiver, +} + +impl InputHandler { + /// Initialize self, and running the message handling loop + pub async fn init( + app_data: Arc>, + rec: Receiver, + docker: Arc, + gui_state: Arc>, + is_running: Arc, + ) { + let mut inner = Self { + app_data, + docker, + gui_state, + is_running, + rec, + }; + inner.start().await; + } + + /// check for incoming messages + async fn start(&mut self) { + while let Ok(message) = self.rec.recv().await { + match message { + InputMessages::ButtonPress(key_code) => self.button_press(key_code).await, + InputMessages::MouseEvent(mouse_event) => { + let show_error = self.app_data.lock().show_error; + let show_info = self.gui_state.lock().show_help; + if !show_error && !show_info { + self.mouse_press(mouse_event); + } + } + } + if !self.is_running.load(Ordering::SeqCst) { + break; + } + } + } + + /// Handle any keyboard button events + async fn button_press(&mut self, key_code: KeyCode) { + let show_error = self.app_data.lock().show_error; + let show_info = self.gui_state.lock().show_help; + if show_error { + match key_code { + KeyCode::Char('q') => { + self.is_running.store(false, Ordering::SeqCst); + } + KeyCode::Char('c') => { + self.app_data.lock().show_error = false; + self.app_data.lock().remove_error(); + } + _ => (), + } + } else if show_info { + match key_code { + KeyCode::Char('q') => { + self.is_running.store(false, Ordering::SeqCst); + } + KeyCode::Char('h') => { + self.gui_state.lock().show_help = false; + } + _ => (), + } + } else { + match key_code { + KeyCode::Char('q') => { + self.is_running.store(false, Ordering::SeqCst); + } + KeyCode::Char('h') => { + self.gui_state.lock().show_help = true; + } + KeyCode::Tab => self.gui_state.lock().next_panel(), + KeyCode::BackTab => self.gui_state.lock().previous_panel(), + KeyCode::Home => { + let mut locked_data = self.app_data.lock(); + match self.gui_state.lock().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(); + match self.gui_state.lock().selected_panel { + SelectablePanel::Containers => locked_data.containers.end(), + SelectablePanel::Logs => locked_data.log_end(), + SelectablePanel::Commands => locked_data.docker_command_end(), + } + } + KeyCode::Up => self.previous(), + KeyCode::PageUp => { + for _ in 0..=6 { + self.previous() + } + } + KeyCode::Down => self.next(), + KeyCode::PageDown => { + for _ in 0..=6 { + self.next() + } + } + KeyCode::Enter => { + // Does is matter though? + // This isn't great, just means you can't send docker commands before full initialization of the program + // could change to to if loading = true, although at the moment don't have a loading bool + let panel = self.gui_state.lock().selected_panel; + if panel == SelectablePanel::Commands { + let command = self.app_data.lock().get_docker_command(); + + if command.is_some() { + let id = self.app_data.lock().get_selected_container_id(); + let app_data = Arc::clone(&self.app_data); + let docker = Arc::clone(&self.docker); + if id.is_some() { + let id = id.unwrap(); + match command.unwrap() { + DockerControls::Pause => { + tokio::spawn(async move { + docker.pause_container(&id).await.unwrap_or_else( + |_| { + app_data.lock().set_error( + AppError::DockerCommand( + DockerControls::Pause, + ), + ) + }, + ); + }); + } + DockerControls::Unpause => { + tokio::spawn(async move { + docker.unpause_container(&id).await.unwrap_or_else( + |_| { + app_data.lock().set_error( + AppError::DockerCommand( + DockerControls::Unpause, + ), + ) + }, + ); + }); + } + DockerControls::Start => { + tokio::spawn(async move { + docker + .start_container( + &id, + None::>, + ) + .await + .unwrap_or_else(|_| { + app_data.lock().set_error( + AppError::DockerCommand( + DockerControls::Start, + ), + ) + }); + }); + } + DockerControls::Stop => { + tokio::spawn(async move { + docker.stop_container(&id, None).await.unwrap_or_else( + |_| { + app_data.lock().set_error( + AppError::DockerCommand( + DockerControls::Stop, + ), + ) + }, + ); + }); + } + DockerControls::Restart => { + tokio::spawn(async move { + docker + .restart_container(&id, None) + .await + .unwrap_or_else(|_| { + app_data.lock().set_error( + AppError::DockerCommand( + DockerControls::Restart, + ), + ) + }); + }); + } + } + } + } + } + } + _ => (), + } + } + } + + /// Handle mouse button events + fn mouse_press(&mut self, mouse_event: MouseEvent) { + match mouse_event.kind { + MouseEventKind::ScrollUp => self.previous(), + MouseEventKind::ScrollDown => self.next(), + MouseEventKind::Down(MouseButton::Left) => { + self.gui_state.lock().rect_insersects(Rect::new( + mouse_event.column, + mouse_event.row, + 1, + 1, + )); + } + _ => (), + } + } + + /// Change state of selected container + fn next(&mut self) { + let mut locked_data = self.app_data.lock(); + match self.gui_state.lock().selected_panel { + SelectablePanel::Containers => locked_data.containers.next(), + SelectablePanel::Logs => locked_data.log_next(), + SelectablePanel::Commands => locked_data.docker_command_next(), + }; + } + + /// Change state of selected container + fn previous(&mut self) { + let mut locked_data = self.app_data.lock(); + match self.gui_state.lock().selected_panel { + SelectablePanel::Containers => locked_data.containers.previous(), + SelectablePanel::Logs => locked_data.log_previous(), + SelectablePanel::Commands => locked_data.docker_command_previous(), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..97a3e50 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,76 @@ +use app_data::AppData; +use app_error::AppError; +use bollard::Docker; +use docker_data::DockerData; +use parking_lot::Mutex; +use parse_args::CliArgs; +use std::sync::{atomic::AtomicBool, Arc}; +use tracing::{info, Level}; + +mod app_data; +mod app_error; +mod docker_data; +mod input_handler; +mod parse_args; +mod ui; + +use ui::{create_ui, GuiState}; + +fn setup_tracing() { + tracing_subscriber::fmt().with_max_level(Level::INFO).init(); +} + +#[tokio::main] +async fn main() { + setup_tracing(); + let args = CliArgs::new(); + let app_data = Arc::new(Mutex::new(AppData::default(args.clone()))); + let gui_state = Arc::new(Mutex::new(GuiState::default())); + + let docker_args = args.clone(); + let docker_app_data = Arc::clone(&app_data); + let docker_gui_state = Arc::clone(&gui_state); + + // Create docker daemon handler, and only spawn up the docker data handler if ping returns non-error + let docker = Arc::new(Docker::connect_with_socket_defaults().unwrap()); + match docker.ping().await { + Ok(_) => { + let docker = Arc::clone(&docker); + tokio::spawn(async move { + DockerData::init(docker_args, docker_app_data, docker, docker_gui_state).await; + }); + } + Err(_) => app_data.lock().set_error(AppError::DockerConnect), + } + + let input_app_data = Arc::clone(&app_data); + + let (s, r) = tokio::sync::broadcast::channel(16); + + let input_docker = Arc::clone(&docker); + let is_running = Arc::new(AtomicBool::new(true)); + let input_is_running = Arc::clone(&is_running); + let input_gui_state = Arc::clone(&gui_state); + + // Spawn input handling into own tokio thread + tokio::spawn(async { + input_handler::InputHandler::init( + input_app_data, + r, + input_docker, + input_gui_state, + input_is_running, + ) + .await; + }); + + // Debug mode for testing, mostly pointless, doesn't take terminal nor draw gui + if !args.gui { + loop { + info!("in debug mode"); + tokio::time::sleep(std::time::Duration::from_millis(5000)).await; + } + } else { + create_ui(app_data, s, is_running, gui_state).await.unwrap(); + } +} diff --git a/src/parse_args/mod.rs b/src/parse_args/mod.rs new file mode 100644 index 0000000..351917a --- /dev/null +++ b/src/parse_args/mod.rs @@ -0,0 +1,50 @@ +use std::process; + +use clap::Parser; +use tracing::error; + +#[derive(Parser, Debug, Clone)] +#[clap(about, version, author)] + +pub struct CliArgs { + /// Docker update interval in ms, minimum 1, reccomended 500+ + #[clap(short = 'd', default_value_t = 1000)] + pub docker: u32, + + /// Don't draw gui - for debugging - mostly pointless + #[clap(short = 'g')] + pub gui: bool, + + /// Remove timestamps from Docker logs + #[clap(short = 't')] + pub timestamp: bool, + + /// Show raw logs, default is to remove ansi formatting + #[clap(short = 'r', conflicts_with = "color")] + pub raw: bool, + + /// Attempt to colorize the logs + #[clap(short = 'c', conflicts_with = "raw")] + pub color: bool, +} + +impl CliArgs { + /// Parse cli arguments + pub fn new() -> Self { + let args = CliArgs::parse(); + + // Quit the program if the docker update argument is 0 + // Should maybe change it to check if less than 100 + if args.docker == 0 { + error!("docker args needs to be greater than 0"); + process::exit(1) + } + Self { + color: args.color, + docker: args.docker, + gui: !args.gui, + raw: args.raw, + timestamp: !args.timestamp, + } + } +} diff --git a/src/ui/color_match.rs b/src/ui/color_match.rs new file mode 100644 index 0000000..14543b6 --- /dev/null +++ b/src/ui/color_match.rs @@ -0,0 +1,77 @@ +pub mod log_sanitizer { + + use cansi::{categorise_text, Color as CansiColor, Intensity}; + use tui::{ + style::{Color, Modifier, Style}, + text::{Span, Spans}, + }; + + /// Attempt to colorize the given string to tui-rs standars + pub fn colorize_logs(input: String) -> Vec> { + vec![Spans::from( + categorise_text(&input) + .into_iter() + .map(|i| { + let fg_color = color_ansi_to_tui(i.fg_colour); + let bg_color = color_ansi_to_tui(i.bg_colour); + let style = Style::default().bg(bg_color).fg(fg_color); + if i.blink { + style.add_modifier(Modifier::SLOW_BLINK); + } + if i.underline { + style.add_modifier(Modifier::UNDERLINED); + } + if i.reversed { + style.add_modifier(Modifier::REVERSED); + } + if i.intensity == Intensity::Bold { + style.add_modifier(Modifier::BOLD); + } + if i.hidden { + style.add_modifier(Modifier::HIDDEN); + } + if i.strikethrough { + style.add_modifier(Modifier::CROSSED_OUT); + } + Span::styled(i.text.to_owned(), style) + }) + .collect::>(), + )] + } + + /// Remove all ansi formatting from a given string and create tui-rs spans + pub fn remove_ansi(input: String) -> Vec> { + let mut output = String::from(""); + for i in categorise_text(&input) { + output.push_str(i.text) + } + raw(output) + } + + /// create tui-rs spans that exactly match the given strings + pub fn raw(input: String) -> Vec> { + vec![Spans::from(Span::raw(input))] + } + + /// Change from ansi to tui colors + fn color_ansi_to_tui(color: CansiColor) -> Color { + match color { + CansiColor::Black => Color::Black, + CansiColor::Red => Color::Red, + CansiColor::Green => Color::Green, + CansiColor::Yellow => Color::Yellow, + CansiColor::Blue => Color::Blue, + CansiColor::Magenta => Color::Magenta, + CansiColor::Cyan => Color::Cyan, + CansiColor::White => Color::White, + CansiColor::BrightBlack => Color::Black, + CansiColor::BrightRed => Color::LightRed, + CansiColor::BrightGreen => Color::LightGreen, + CansiColor::BrightYellow => Color::LightYellow, + CansiColor::BrightBlue => Color::LightBlue, + CansiColor::BrightMagenta => Color::LightMagenta, + CansiColor::BrightCyan => Color::LightCyan, + CansiColor::BrightWhite => Color::White, + } + } +} diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs new file mode 100644 index 0000000..8bdda8d --- /dev/null +++ b/src/ui/draw_blocks.rs @@ -0,0 +1,598 @@ +use parking_lot::Mutex; +use std::default::Default; +use std::{fmt::Display, sync::Arc}; +use tui::{ + backend::Backend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{Span, Spans}, + widgets::{ + Axis, Block, BorderType, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, + Paragraph, + }, + Frame, +}; + +use crate::{ + app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats}, + app_error::AppError, +}; + +use super::{GuiState, SelectablePanel}; + +const NAME_TEXT: &str = r#" + 88 + 88 + 88 + ,adPPYba, 8b, ,d8 88 ,d8 ,adPPYba, 8b,dPPYba, +a8" "8a `Y8, ,8P' 88 ,a8" a8P_____88 88P' "Y8 +8b d8 )888( 8888[ 8PP""""""" 88 +"8a, ,a8" ,d8" "8b, 88`"Yba, "8b, ,aa 88 + `"YbbdP"' 8P' `Y8 88 `Y8a `"Ybbd8"' 88 "#; + +const NAME: &str = env!("CARGO_PKG_NAME"); +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const REPO: &str = env!("CARGO_PKG_REPOSITORY"); +const ORANGE: Color = Color::Rgb(255, 178, 36); +const MARGIN: &str = " "; + +/// Generate block, add a bored if is the selected panel, +/// add custom title based on state of each panel +fn generate_block<'a>( + selectable_panel: Option, + app_data: &Arc>, + selected_panel: &SelectablePanel, +) -> Block<'a> { + let mut block = Block::default().borders(Borders::ALL); + + if let Some(panel) = selectable_panel { + let title = match panel { + SelectablePanel::Containers => { + format!( + " {} {} ", + panel.title(), + app_data.lock().containers.get_state_title() + ) + } + SelectablePanel::Logs => { + format!(" {} {} ", panel.title(), app_data.lock().get_log_title()) + } + _ => String::from(""), + }; + block = block.title(title); + if selected_panel == &panel { + let selected_style = Style::default().fg(Color::LightCyan); + let selected_border = BorderType::Plain; + block = block + .border_style(selected_style) + .border_type(selected_border); + } + } + block +} + +/// Draw the selectable panels +pub fn draw_commands( + app_data: &Arc>, + area: Rect, + f: &mut Frame<'_, B>, + gui_state: &Arc>, + index: Option, + selected_panel: &SelectablePanel, +) { + let panel = SelectablePanel::Commands; + let block = generate_block(Some(panel), app_data, selected_panel); + + gui_state.lock().insert_into_area_map(panel, area); + + if let Some(i) = index { + let items = app_data.lock().containers.items[i] + .docker_controls + .items + .iter() + .map(|i| { + let lines = Spans::from(vec![Span::styled( + i.to_string(), + Style::default().fg(i.get_color()), + )]); + ListItem::new(lines) + }) + .collect::>(); + + let items = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget( + items, + area, + &mut app_data.lock().containers.items[i].docker_controls.state, + ); + } else { + let debug_text = String::from(""); + let paragraph = Paragraph::new(debug_text) + .block(block) + .alignment(Alignment::Center); + f.render_widget(paragraph, area) + } +} + +/// Draw the selectable panels +pub fn draw_containers( + app_data: &Arc>, + area: Rect, + f: &mut Frame<'_, B>, + gui_state: &Arc>, + selected_panel: &SelectablePanel, + widths: &Columns, +) { + let panel = SelectablePanel::Containers; + let block = generate_block(Some(panel), app_data, selected_panel); + + gui_state.lock().insert_into_area_map(panel, area); + + let items = app_data + .lock() + .containers + .items + .iter() + .map(|i| { + let state_style = Style::default().fg(i.state.get_color()); + let blue = Style::default().fg(Color::Blue); + + let mems = format!( + "{:>1} / {:>1}", + i.mem_stats.back().unwrap_or(&ByteStats::new(0)), + i.mem_limit + ); + + let lines = Spans::from(vec![ + Span::styled( + format!("{:width$}", MARGIN, i.status, width = widths.status.1), + state_style, + ), + Span::styled( + format!( + "{}{:>width$}", + MARGIN, + i.cpu_stats.back().unwrap_or(&CpuStats::new(0.0)), + width = widths.cpu.1 + ), + state_style, + ), + Span::styled( + format!("{}{:>width$}", MARGIN, mems, width = widths.mem.1), + state_style, + ), + Span::styled( + format!("{}{:>width$}", MARGIN, i.name, width = widths.name.1), + blue, + ), + Span::styled( + format!("{}{:>width$}", MARGIN, i.image, width = widths.image.1), + blue, + ), + Span::styled( + format!("{}{:>width$}", MARGIN, i.net_rx, width = widths.net_rx.1), + Style::default().fg(Color::Rgb(255, 233, 193)), + ), + Span::styled( + format!("{}{:>width$}", MARGIN, i.net_tx, width = widths.net_tx.1), + Style::default().fg(Color::Rgb(205, 140, 140)), + ), + ]); + ListItem::new(lines) + }) + .collect::>(); + + if items.is_empty() { + let debug_text = String::from("no containers running"); + let paragraph = Paragraph::new(debug_text) + .block(block) + .alignment(Alignment::Center); + f.render_widget(paragraph, area) + } else { + let items = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol("⚪ "); + + f.render_stateful_widget(items, area, &mut app_data.lock().containers.state); + } +} + +/// Draw the selectable panels +pub fn draw_logs( + app_data: &Arc>, + area: Rect, + f: &mut Frame<'_, B>, + gui_state: &Arc>, + index: Option, + selected_panel: &SelectablePanel, +) { + let panel = SelectablePanel::Logs; + + gui_state.lock().insert_into_area_map(panel, area); + + let block = generate_block(Some(panel), app_data, selected_panel); + + let init = app_data.lock().init; + if !init { + let icon = gui_state.lock().get_loading(); + let parsing_logs = format!("parsing logs {}", icon); + let paragraph = Paragraph::new(parsing_logs) + .style(Style::default()) + .block(block) + .alignment(Alignment::Center); + f.render_widget(paragraph, area) + + } else if let Some(index) = index { + let items = app_data.lock().containers.items[index] + .logs + .items + .iter() + .enumerate() + .map(|i| i.1.to_owned()) + .collect::>(); + + let items = List::new(items) + .block(block) + .highlight_symbol("▶ ") + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + f.render_stateful_widget( + items, + area, + &mut app_data.lock().containers.items[index].logs.state, + ); + } else { + let debug_text = String::from("no logs found"); + let paragraph = Paragraph::new(debug_text) + .block(block) + .alignment(Alignment::Center); + f.render_widget(paragraph, area) + } +} + +/// Draw the cpu + mem charts +pub fn draw_chart( + f: &mut Frame<'_, B>, + area: Rect, + app_data: &Arc>, + index: Option, +) { + if let Some(index) = index { + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(area); + let (cpu, mem) = app_data.lock().containers.items[index].get_chart_data(); + + let cpu_dataset = vec![Dataset::default() + .marker(symbols::Marker::Dot) + .style(Style::default().fg(Color::Magenta)) + .graph_type(GraphType::Line) + .data(&cpu.0)]; + + let mem_dataset = vec![Dataset::default() + .marker(symbols::Marker::Dot) + .style(Style::default().fg(Color::Cyan)) + .graph_type(GraphType::Line) + .data(&mem.0)]; + let cpu_chart = make_chart( + cpu.2, + String::from("cpu"), + cpu_dataset, + CpuStats::new(cpu.0.last().unwrap_or(&(0.00, 0.00)).1), + cpu.1, + ); + let mem_chart = make_chart( + mem.2, + String::from("memory"), + mem_dataset, + ByteStats::new(mem.0.last().unwrap_or(&(0.0, 0.0)).1 as u64), + mem.1, + ); + + f.render_widget(cpu_chart, area[0]); + f.render_widget(mem_chart, area[1]); + } +} + +/// Create charts +fn make_chart( + state: State, + name: String, + dataset: Vec, + current: T, + max: T, +) -> Chart { + let title_color = match state { + State::Running => Color::Green, + _ => state.get_color(), + }; + let label_color = match state { + State::Running => ORANGE, + _ => state.get_color(), + }; + Chart::new(dataset) + .block( + Block::default() + .title_alignment(Alignment::Center) + .title(Span::styled( + format!(" {} {} ", name, current), + Style::default() + .fg(title_color) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(BorderType::Plain), + ) + .x_axis( + Axis::default() + .style(Style::default().fg(title_color)) + .bounds([0.00, 60.0]), + ) + .y_axis( + Axis::default() + .labels(vec![ + Span::styled("", Style::default().fg(label_color)), + Span::styled( + format!("{}", max), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(label_color), + ), + ]) + // add 0.01, for cases when the value is 0 + .bounds([0.0, max.get_value() +0.01]), + ) +} + +/// Show error popup over whole screen +pub fn draw_info_bar( + area: Rect, + columns: &Columns, + f: &mut Frame<'_, B>, + has_containers: bool, + info_visible: bool, +) { + let block = || Block::default().style(Style::default().bg(Color::Magenta).fg(Color::Black)); + + f.render_widget(block(), area); + + let mut column_headings = format!(" {:>width$}", columns.state.0, width = columns.state.1); + column_headings.push_str( + format!( + "{} {:>width$}", + MARGIN, + columns.status.0, + width = columns.status.1 + ) + .as_str(), + ); + column_headings + .push_str(format!("{}{:>width$}", MARGIN, columns.cpu.0, width = columns.cpu.1).as_str()); + column_headings + .push_str(format!("{}{:>width$}", MARGIN, columns.mem.0, width = columns.mem.1).as_str()); + column_headings.push_str( + format!( + "{}{:>width$}", + MARGIN, + columns.name.0, + width = columns.name.1 + ) + .as_str(), + ); + column_headings.push_str( + format!( + "{}{:>width$}", + MARGIN, + columns.image.0, + width = columns.image.1 + ) + .as_str(), + ); + column_headings.push_str( + format!( + "{}{:>width$}", + MARGIN, + columns.net_rx.0, + width = columns.net_rx.1 + ) + .as_str(), + ); + column_headings.push_str( + format!( + "{}{:>width$}", + MARGIN, + columns.net_tx.0, + width = columns.net_tx.1 + ) + .as_str(), + ); + + let suffix = if info_visible { "exit" } else { "show" }; + let info_text = format!("( h ) to {} help {}", suffix, MARGIN); + let info_width = info_text.chars().count(); + + let column_width = column_headings.chars().count(); + + let splits = if has_containers { + vec![ + Constraint::Min(column_width as u16), + Constraint::Min(info_width as u16), + ] + } else { + vec![Constraint::Percentage(100)] + }; + + let split_bar = Layout::default() + .direction(Direction::Horizontal) + .constraints(splits.as_ref()) + .split(area); + + if has_containers { + let paragraph = Paragraph::new(column_headings) + .block(block()) + .alignment(Alignment::Left); + f.render_widget(paragraph, split_bar[0]); + } + + let paragraph = Paragraph::new(info_text) + .block(block()) + .alignment(Alignment::Right); + + let index = if has_containers { 1 } else { 0 }; + f.render_widget(paragraph, split_bar[index]); +} + +/// Show error popup over whole screen +pub fn draw_help_box(f: &mut Frame<'_, B>) { + let title = format!(" {} ", VERSION); + + let mut description_text = + String::from("\n A basic docker container information viewer and controller."); + description_text.push_str("\n Tab or Alt+Tab to change panels, arrows to change lines, enter to send docker container commands."); + description_text.push_str("\n Mouse input also available."); + description_text.push_str("\n ( q ) to quit at any time."); + description_text + .push_str("\n\n currenty an early work in progress, all and any input appreciated"); + description_text.push_str(format!("\n {}", REPO.trim()).as_str()); + + let mut max_line_width = 0; + + let all_text = format!("{}{}", NAME_TEXT, description_text); + + all_text.lines().into_iter().for_each(|line| { + let width = line.chars().count(); + if width > max_line_width { + max_line_width = width; + } + }); + + let mut lines = all_text.lines().count(); + + // Add some vertical and horizontal padding to the info box + lines += 3; + max_line_width += 4; + + let name_paragraph = Paragraph::new(NAME_TEXT) + .style(Style::default().bg(Color::Magenta).fg(Color::White)) + .block(Block::default()) + .alignment(Alignment::Center); + + let description_paragraph = Paragraph::new(description_text.as_str()) + .style(Style::default().bg(Color::Magenta).fg(Color::Black)) + .block(Block::default()) + .alignment(Alignment::Left); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Black)); + + let area = centered_info(lines as u16, max_line_width as u16, f.size()); + + let split_popup = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Max(NAME_TEXT.lines().count() as u16), + Constraint::Max(description_text.lines().count() as u16), + ] + .as_ref(), + ) + .split(area); + + // Order is important here + f.render_widget(Clear, area); + f.render_widget(name_paragraph, split_popup[0]); + f.render_widget(description_paragraph, split_popup[1]); + f.render_widget(block, area); +} + +/// Show error popup over whole screen +pub fn draw_error(f: &mut Frame<'_, B>, error: AppError, seconds: Option) { + let block = Block::default() + .title(" Error ") + .border_type(BorderType::Rounded) + .title_alignment(Alignment::Center) + .borders(Borders::ALL); + + let to_push = match error { + AppError::DockerConnect => { + format!( + "\n\n {}::v{} closing in {:02} seconds", + NAME, + VERSION, + seconds.unwrap_or(5) + ) + } + _ => String::from("\n\n ( c ) to clear error\n ( q ) to quit oxker"), + }; + + let mut text = format!("\n{}", error); + + text.push_str(to_push.as_str()); + + let mut max_line_width = 0; + text.lines().into_iter().for_each(|line| { + let width = line.chars().count(); + if width > max_line_width { + max_line_width = width; + } + }); + + let mut lines = text.lines().count(); + + // Add some horizontal & vertical margins + max_line_width += 8; + lines += 3; + + let paragraph = Paragraph::new(text) + .style(Style::default().bg(Color::Red).fg(Color::White)) + .block(block) + .alignment(Alignment::Center); + + let area = centered_info(lines as u16, max_line_width as u16, f.size()); + f.render_widget(Clear, area); + f.render_widget(paragraph, area); +} + +/// draw a box in the center of the screen, based on max line width + number of lines +fn centered_info(number_lines: u16, max_line_width: u16, r: Rect) -> Rect { + // This can panic if number_lines or max_line_width is larger than r.height or r.width + let blank_vertical = (r.height - number_lines) / 2; + let blank_horizontal = (r.width - max_line_width) / 2; + + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Max(blank_vertical), + Constraint::Max(number_lines), + Constraint::Max(blank_vertical), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Max(blank_horizontal), + Constraint::Max(max_line_width), + Constraint::Max(blank_horizontal), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs new file mode 100644 index 0000000..39e3d40 --- /dev/null +++ b/src/ui/gui_state.rs @@ -0,0 +1,161 @@ +use std::{collections::HashMap, fmt}; +use tui::layout::Rect; + +#[derive(Debug, PartialEq, std::hash::Hash, std::cmp::Eq, Clone, Copy)] +pub enum SelectablePanel { + Containers, + Commands, + Logs, +} +#[derive(Debug)] +pub enum Loading { + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, + Nine, + Ten, +} + +impl Loading { + pub fn next(&self) -> Self { + match self { + Self::One => Self::Two, + Self::Two => Self::Three, + Self::Three => Self::Four, + Self::Four => Self::Five, + Self::Five => Self::Six, + Self::Six => Self::Seven, + Self::Seven => Self::Eight, + Self::Eight => Self::Nine, + Self::Nine => Self::Ten, + Self::Ten => Self::One, + // Self::Five => Self::One + } + } +} +// "⠋", +// "⠙", +// "⠹", +// "⠸", +// "⠼", +// "⠴", +// "⠦", +// "⠧", +// "⠇", +// "⠏" + +impl fmt::Display for Loading { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let disp = match self { + Self::One => "⠋", + Self::Two => "⠙", + Self::Three => "⠹", + Self::Four => "⠸", + Self::Five => "⠼", + Self::Six => "⠴", + Self::Seven => "⠦", + Self::Eight => "⠧", + Self::Nine => "⠇", + Self::Ten => "⠏", + }; + write!(f, "{}", disp) + } +} + +impl SelectablePanel { + pub fn title(self) -> &'static str { + match self { + Self::Containers => "Containers", + Self::Logs => "Logs", + _ => "", + } + } + pub fn next(self) -> Self { + match self { + Self::Containers => Self::Commands, + Self::Commands => Self::Logs, + Self::Logs => Self::Containers, + } + } + pub fn prev(self) -> Self { + match self { + Self::Containers => Self::Logs, + Self::Commands => Self::Containers, + Self::Logs => Self::Commands, + } + } +} + +/// Global gui_state, stored in an Arc +#[derive(Debug)] +pub struct GuiState { + // Think this should be a BMapTree, so can define order when iterating over potential intersects + // Is an issue if two panels are in the same space, sush as a smaller panel embedded, yet infront of, a larger panel + // If a BMapTree think it would mean have to implement ordering for SelectablePanel + area_map: HashMap, + loading: Loading, + pub selected_panel: SelectablePanel, + pub show_help: bool, +} + +impl GuiState { + /// Generate a default gui_state + pub fn default() -> Self { + Self { + area_map: HashMap::new(), + loading: Loading::One, + selected_panel: SelectablePanel::Containers, + show_help: false, + } + } + + /// clear panels hash map, so on resize can fix the sizes for mouse clicks + pub fn clear_area_map(&mut self) { + self.area_map.clear(); + } + + /// Check if a given Rect (a clicked area of 1x1), interacts with any known panels + pub fn rect_insersects(&mut self, rect: Rect) { + if let Some(data) = self + .area_map + .iter() + .filter(|i| i.1.intersects(rect)) + .collect::>() + .get(0) + { + self.selected_panel = *data.0; + } + } + + /// Insert selectable gui panel into area map + pub fn insert_into_area_map(&mut self, panel: SelectablePanel, area: Rect) { + self.area_map.entry(panel).or_insert(area); + } + + /// Change to next selectable panel + pub fn next_panel(&mut self) { + self.selected_panel = self.selected_panel.next(); + } + + /// Change to previous selectable panel + pub fn previous_panel(&mut self) { + self.selected_panel = self.selected_panel.prev(); + } + + pub fn next_loading(&mut self) { + self.loading = self.loading.next() + } + + pub fn get_loading(&mut self) -> String { + self.loading.to_string() + } + + pub fn reset_loading(&mut self) { + self.loading = Loading::One; + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..a7b28f1 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,215 @@ +use anyhow::Result; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use parking_lot::Mutex; +use std::sync::atomic::AtomicBool; +use std::{ + io, + sync::{atomic::Ordering, Arc}, +}; +use tokio::sync::broadcast::Sender; +use tui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout}, + Frame, Terminal, +}; + +mod color_match; +mod draw_blocks; +mod gui_state; + +pub use self::color_match::*; +pub use self::gui_state::{GuiState, SelectablePanel}; +use crate::{app_data::AppData, app_error::AppError, input_handler::InputMessages}; +use draw_blocks::*; + +/// Take control of the terminal in order to draw gui +pub async fn create_ui( + app_data: Arc>, + sender: Sender, + is_running: Arc, + gui_state: Arc>, +) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = run_app(&mut terminal, app_data, sender, is_running, gui_state).await; + + disable_raw_mode().unwrap(); + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor().unwrap(); + + if let Err(err) = res { + err.disp() + } + Ok(()) +} + +/// Run a loop to draw the gui +async fn run_app( + terminal: &mut Terminal, + app_data: Arc>, + sender: Sender, + is_running: Arc, + gui_state: Arc>, +) -> Result<(), AppError> { + let input_poll_rate = std::time::Duration::from_millis(75); + + // Check for docker connect errors before attempting to draw the gui + let e = app_data.lock().get_error(); + if let Some(error) = e { + if let AppError::DockerConnect = error { + let mut seconds = 5; + loop { + if seconds < 1 { + is_running.store(false, Ordering::SeqCst); + break; + } + terminal + .draw(|f| draw_error(f, AppError::DockerConnect, Some(seconds))) + .unwrap(); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + seconds -= 1; + } + } + } else { + loop { + terminal.draw(|f| ui(f, &app_data, &gui_state)).unwrap(); + if crossterm::event::poll(input_poll_rate).unwrap() { + let event = event::read().unwrap(); + if let Event::Key(key) = event { + sender + .send(InputMessages::ButtonPress(key.code)) + .unwrap_or(0); + } else if let Event::Mouse(m) = event { + sender.send(InputMessages::MouseEvent(m)).unwrap_or(0); + } else if let Event::Resize(_, _) = event { + gui_state.lock().clear_area_map(); + terminal.autoresize().unwrap_or(()); + } + } + + if !is_running.load(Ordering::SeqCst) { + break; + } + } + } + Ok(()) +} + +fn ui( + f: &mut Frame<'_, B>, + app_data: &Arc>, + gui_state: &Arc>, +) { + // set max height for container section, needs +4 to deal with docker commands list and borders + let mut height = app_data.lock().get_container_len(); + if height < 12 { + height += 4; + } else { + height = 12 + } + + let column_widths = app_data.lock().get_width(); + let has_containers = !app_data.lock().containers.items.is_empty(); + let has_error = app_data.lock().get_error(); + let log_index = app_data.lock().get_selected_log_index(); + let selected_panel = gui_state.lock().selected_panel; + let show_help = gui_state.lock().show_help; + + let whole_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Min(100)].as_ref()) + .split(f.size()); + + // Split into 3, containers+controls, logs, then graphs + let upper_main = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Max(height as u16), Constraint::Percentage(50)].as_ref()) + .split(whole_layout[1]); + + let top_split = if has_containers { + vec![Constraint::Percentage(90), Constraint::Percentage(10)] + } else { + vec![Constraint::Percentage(100)] + }; + // Containers + docker commands + let top_panel = Layout::default() + .direction(Direction::Horizontal) + .constraints(top_split.as_ref()) + .split(upper_main[0]); + + let lower_split = if has_containers { + vec![Constraint::Percentage(75), Constraint::Percentage(25)] + } else { + vec![Constraint::Percentage(100)] + }; + + // Split into 3, containers+controls, logs, then graphs + let lower_main = Layout::default() + .direction(Direction::Vertical) + .constraints(lower_split.as_ref()) + .split(upper_main[1]); + + draw_containers( + app_data, + top_panel[0], + f, + gui_state, + &selected_panel, + &column_widths, + ); + + if has_containers { + draw_commands( + app_data, + top_panel[1], + f, + gui_state, + log_index, + &selected_panel, + ); + } + + draw_logs( + app_data, + lower_main[0], + f, + gui_state, + log_index, + &selected_panel, + ); + + draw_info_bar( + whole_layout[0], + &column_widths, + f, + has_containers, + show_help, + ); + + // only draw charts if there are containers + if has_containers { + draw_chart(f, lower_main[1], app_data, log_index); + } + + // Check if error, and show popup if so + if show_help { + draw_help_box(f); + } + + if let Some(error) = has_error { + app_data.lock().show_error = true; + draw_error(f, error, None); + } +}