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);
+ }
+}