init commit

This commit is contained in:
Jack Wills
2022-04-25 02:44:39 +00:00
commit 5101f60aaa
28 changed files with 3289 additions and 0 deletions
+9
View File
@@ -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
+58
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
*.sh eol=lf
*.md eol=CRLF
*.txt eol=lf
+30
View File
@@ -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.
+20
View File
@@ -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.
+20
View File
@@ -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.
+73
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

@@ -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 }}
+3
View File
@@ -0,0 +1,3 @@
/target
/releases
Cargo.lock
+1
View File
@@ -0,0 +1 @@
+ init commit
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "oxker"
version = "0.0.1"
edition = "2021"
authors = ["Jack Wills <email@mrjackwills.com>"]
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
+21
View File
@@ -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.
+91
View File
@@ -0,0 +1,91 @@
<p align="center">
<img src='./.github/logo.svg' width='200px'/>
</p>
<p align="center">
<h1 align="center">oxker</h1>
</p>
<p align="center">
A simple tui to view and control docker containers"
</p>
<p align="center">
Built in <a href='https://www.rust-lang.org/' target='_blank' rel='noopener noreferrer'>Rust</a>, making heavy use of <a href='https://github.com/fdehau/tui-rs' target='_blank' rel='noopener noreferrer'>tui-rs</a> & <a href='https://github.com/fussybeaver/bollard' target='_blank' rel='noopener noreferrer'>Bollard</a>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/screenshot_01.jpg" target='_blank' rel='noopener noreferrer'>
<img src='./.github/screenshot_01.jpg' width='100%'/>
</a>
</p>
## Download
See <a href="https://github.com/mrjackwills/oxker/releases" target='_blank' rel='noopener noreferrer'>releases</a>
## 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 & <a href='https://github.com/cross-rs/cross' target='_blank' rel='noopener noreferrer'>cross-rs</a>
#### 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 <a href="https://forums.raspberrypi.com/viewtopic.php?t=203128" target='_blank' rel='noopener noreferrer'>https://forums.raspberrypi.com/viewtopic.php?t=203128</a> and <a href="https://github.com/docker/for-linux/issues/1112" target='_blank' rel='noopener noreferrer'>https://github.com/docker/for-linux/issues/1112</a>
### 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```
+248
View File
@@ -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 '/# <a href=/Q' CHANGELOG.md)
printf "%s" "$RELEASE_BODY_TEXT"
printf "\n%s\n" "${STAR_LINE}"
ask_yn "accept release body"
if [[ "$(user_input)" =~ ^y$ ]]
then
update_release_body_and_changelog "$RELEASE_BODY_TEXT"
else
exit
fi
}
# Edit the release-body to include new liens from changelog
# add commit urls to changelog
# $1 RELEASE_BODY
update_release_body_and_changelog () {
echo -e
DATE_SUBHEADING="### $(date +'%Y-%m-%d')\n\n"
RELEASE_BODY_ADDITION="${DATE_SUBHEADING}$1"
echo -e "${RELEASE_BODY_ADDITION}\n\nsee <a href='${GIT_REPO_URL}/blob/main/CHANGELOG.md'>CHANGELOG.md</a> for more details" > .github/release-body.md
echo -e "# <a href='${GIT_REPO_URL}/releases/tag/${NEW_TAG_VERSION}'>${NEW_TAG_VERSION}</a>\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
+425
View File
@@ -0,0 +1,425 @@
use std::{cmp::Ordering, collections::VecDeque, fmt};
use tui::{
style::Color,
widgets::{ListItem, ListState},
};
#[derive(Debug, Clone)]
pub struct StatefulList<T> {
pub state: ListState,
pub items: Vec<T>,
}
impl<T> StatefulList<T> {
pub fn new(items: Vec<T>) -> 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<Self> {
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<Ordering> {
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<Ordering> {
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<CpuStats>,
pub docker_controls: StatefulList<DockerControls>,
pub id: String,
pub image: String,
pub last_updated: u64,
pub logs: StatefulList<ListItem<'static>>,
pub mem_limit: ByteStats,
pub mem_stats: VecDeque<ByteStats>,
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::<Vec<_>>()
}
/// 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::<Vec<_>>()
}
/// 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),
}
}
}
+397
View File
@@ -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<Mutex>
#[derive(Debug)]
pub struct AppData {
args: CliArgs,
error: Option<AppError>,
logs_parsed: bool,
pub containers: StatefulList<ContainerItem>,
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<DockerControls> {
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<AppError> {
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<String> {
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::<String>();
output = Some(id)
}
output
}
/// Find the index of the currently selected single log line
pub fn get_selected_log_index(&self) -> Option<usize> {
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<String> {
self.containers
.items
.iter()
.map(|i| i.id.to_owned())
.collect::<Vec<_>>()
}
/// 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<f64>,
mem_stat: Option<u64>,
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<String>, 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<Vec<String>>) {
for (index, output) in all_logs.into_iter().enumerate() {
self.update_log_by_index(output, index);
}
}
}
+45
View File
@@ -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)
}
}
+277
View File
@@ -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<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
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<Docker>,
id: String,
app_data: Arc<Mutex<AppData>>,
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::<String> {
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::<Vec<_>>()
}
/// Update single container logs
/// don't take &self, so that can tokio::spawn into it's on thread
async fn update_log(
docker: Arc<Docker>,
id: String,
timestamps: bool,
since: i64,
) -> Vec<String> {
let options = Some(LogsOptions::<String> {
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<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
) {
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
+7
View File
@@ -0,0 +1,7 @@
use crossterm::event::{KeyCode, MouseEvent};
#[derive(Debug, Clone)]
pub enum InputMessages {
ButtonPress(KeyCode),
MouseEvent(MouseEvent),
}
+262
View File
@@ -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<Mutex<AppData>>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
rec: Receiver<InputMessages>,
}
impl InputHandler {
/// Initialize self, and running the message handling loop
pub async fn init(
app_data: Arc<Mutex<AppData>>,
rec: Receiver<InputMessages>,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
) {
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::<StartContainerOptions<String>>,
)
.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(),
}
}
}
+76
View File
@@ -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();
}
}
+50
View File
@@ -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,
}
}
}
+77
View File
@@ -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<Spans<'static>> {
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::<Vec<_>>(),
)]
}
/// Remove all ansi formatting from a given string and create tui-rs spans
pub fn remove_ansi(input: String) -> Vec<Spans<'static>> {
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<Spans<'static>> {
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,
}
}
}
+598
View File
@@ -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<SelectablePanel>,
app_data: &Arc<Mutex<AppData>>,
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<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
index: Option<usize>,
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::<Vec<_>>();
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<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
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$}", i.state.to_string(), width = widths.state.1),
state_style,
),
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::<Vec<_>>();
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<B: Backend>(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
f: &mut Frame<'_, B>,
gui_state: &Arc<Mutex<GuiState>>,
index: Option<usize>,
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::<Vec<_>>();
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<B: Backend>(
f: &mut Frame<'_, B>,
area: Rect,
app_data: &Arc<Mutex<AppData>>,
index: Option<usize>,
) {
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<T: Stats + Display>(
state: State,
name: String,
dataset: Vec<Dataset>,
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<B: Backend>(
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<B: Backend>(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<B: Backend>(f: &mut Frame<'_, B>, error: AppError, seconds: Option<u8>) {
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]
}
+161
View File
@@ -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<Mutex>
#[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<SelectablePanel, Rect>,
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::<Vec<_>>()
.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;
}
}
+215
View File
@@ -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<Mutex<AppData>>,
sender: Sender<InputMessages>,
is_running: Arc<AtomicBool>,
gui_state: Arc<Mutex<GuiState>>,
) -> 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<B: Backend>(
terminal: &mut Terminal<B>,
app_data: Arc<Mutex<AppData>>,
sender: Sender<InputMessages>,
is_running: Arc<AtomicBool>,
gui_state: Arc<Mutex<GuiState>>,
) -> 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<B: Backend>(
f: &mut Frame<'_, B>,
app_data: &Arc<Mutex<AppData>>,
gui_state: &Arc<Mutex<GuiState>>,
) {
// 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);
}
}