feat: complete Rust-to-Go rewrite with Bubbletea v2

Rewrites oxker from Rust/ratatui to Go/Bubbletea, migrated to the
Bubbletea v2 API (charm.land/bubbletea/v2). Removes all original Rust
source files and legacy Go modules (internal/ui, internal/input, bubbles).

Key changes:
- View() returns tea.View with declarative AltScreen and MouseMode
- KeyMsg → KeyPressMsg, MouseMsg → MouseClickMsg/WheelMsg/MotionMsg/ReleaseMsg
- execWriteKey rewritten for v2 key fields (Code/Mod/Text)
- Mouse toggle via View field instead of imperative commands
- Filter/search text input uses msg.Text for v2 space key compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Niko Syring
2026-03-12 03:41:14 +01:00
parent e020eb157c
commit d41761d6e9
224 changed files with 5140 additions and 26515 deletions
-12
View File
@@ -1,12 +0,0 @@
ARG VARIANT="bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/base: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
ENV PATH="/home/vscode/.cargo/bin:${PATH}"
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends build-essential pkg-config libssl-dev
USER vscode
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
-53
View File
@@ -1,53 +0,0 @@
// 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"
],
"postCreateCommand": "cargo install cross typos-cli cargo-expand cargo-insta",
"customizations": {
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"bmuskalla.vscode-tldr",
"christian-kohler.path-intellisense",
"citreae535.sparse-crates",
"foxundermoon.shell-format",
"gruntfuggly.todo-tree",
"mutantdino.resourcemonitor",
"redhat.vscode-yaml",
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"timonwong.shellcheck",
"vadimcn.vscode-lldb"
],
// 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
}
}
}
},
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2":{},
"ghcr.io/devcontainers/features/git:1": {
"version":"os-provided"
}
}
}
-30
View File
@@ -1,30 +0,0 @@
---
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
@@ -1,20 +0,0 @@
---
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
@@ -1,20 +0,0 @@
---
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

-73
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

-25
View File
@@ -1,25 +0,0 @@
### 2026-02-23
*BREAKING CHANGES*
+ `log_scroll_forward`, `log_scroll_back` renamed to `scroll_forward`, `scroll_back`
+ Additional KeyMap entry, `inspect` defaults to `i`, enables Inspect mode
+ Docker Host priorities reordered, *should* now be, from high to low order, `--host` cli argument, `config.toml` `host` value, `DOCKER_HOST` env, [Docker library](https://github.com/fussybeaver/bollard) default setting.
+ `config.toml` `host` value is now commented out by default - this should help with invalid Docker connection errors and enable easy Podman support
### Chores
+ dependencies updated, [4658a8de264698b0c8092e1227f0683527219a0b], [8b5899ca238bcbff32519b376b920cd7b7509809], [bebb687c59f3b408e69b23d2e68fa69f006a3231]
+ GitHub workflow updated, [a0aa7918241ee8f702d6472c620287aa4be7d56c]
### Features
+ Network chart, closes #79, [99fcb8fedf01599ec346b65d435d4c301a7a8851]
+ Inspect mode & help panel redesign, [ae7f3f4a9472b451c37c0ab97b1756b41a3529f5]
+ set rust-version in Cargo.toml, closes #77, [0763a1024f44d98b8d9d65f57995da538e40963c]
### Fixes
+ Enable quit on Docker connect error screen, [5f942eb2e963660bd7fe9d80fa7ba8a83754803a]
### Refactors
+ dead code removed, [3e31a2a6bc02d6ef75bd6cbc18568e82e60e1ee3]
+ docker data spawns, [cd943f67e465fff9726b40570a089301a4a8f534]
see <a href='https://github.com/mrjackwills/oxker/blob/main/CHANGELOG.md'>CHANGELOG.md</a> for more details
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

@@ -1,186 +0,0 @@
name: Release CI
permissions:
contents: write
packages: write
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
#################################################
## Cross platform binary build for release page #
#################################################
cross_platform_build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
output_name: linux_x86_64.tar.gz
- target: aarch64-unknown-linux-musl
output_name: linux_aarch64.tar.gz
- target: arm-unknown-linux-musleabihf
output_name: linux_armv6.tar.gz
- target: aarch64-apple-darwin
output_name: apple_darwin_aarch64.tar.gz
- target: x86_64-pc-windows-gnu
output_name: windows_x86_64.zip
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
# Install stable rust, and associated tools
- name: install rust
uses: dtolnay/rust-toolchain@stable
# Install cross-rs
- name: install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: build
if: matrix.target == 'aarch64-apple-darwin'
run: |
docker run --rm \
-v "$(pwd):/io" \
-w /io \
ghcr.io/rust-cross/cargo-zigbuild \
bash -ec '
rustup update stable
rustup default stable
rustup target add aarch64-apple-darwin
cargo zigbuild --release --target aarch64-apple-darwin
'
# Build all other targets using Cross
- name: build
if: matrix.target != 'aarch64-apple-darwin'
run: cross build --target ${{ matrix.target }} --release
# Compress the output
- name: compress windows
if: matrix.target == 'x86_64-pc-windows-gnu'
run: |
zip -j "./oxker_${{ matrix.output_name }}" target/${{ matrix.target }}/release/oxker.exe
# Compress the output
- name: compress linux
if: matrix.target != 'x86_64-pc-windows-gnu'
run: |
tar -C "target/${{ matrix.target }}/release" -czf "./oxker_${{ matrix.output_name }}" oxker
# Upload output for release page
- name: Upload Artifacts
uses: actions/upload-artifact@v6
with:
if-no-files-found: error
name: ${{ matrix.target }}
path: oxker_${{ matrix.output_name }}
retention-days: 1
###################
## Create release #
###################
create_release:
needs: [cross_platform_build]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup | Artifacts
uses: actions/download-artifact@v7
- name: Update Release
uses: ncipollo/release-action@v1
with:
makeLatest: true
name: ${{ github.ref_name }}
tag: ${{ github.ref }}
bodyFile: ".github/release-body.md"
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
**/oxker_*.zip
**/oxker_*.tar.gz
#########################################
## Build images for Dockerhub & ghcr.io #
#########################################
container_image_build:
needs: [create_release]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Write release version to env
run: |
CURRENT_SEMVER=${GITHUB_REF_NAME#v}
echo "CURRENT_SEMVER=$CURRENT_SEMVER" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3
id: buildx
with:
install: true
- name: Build for Dockerhub & ghcr.io
run: |
docker build --platform linux/arm/v6,linux/arm64,linux/amd64 \
-t ${{ secrets.DOCKERHUB_USERNAME }}/oxker:latest \
-t ${{ secrets.DOCKERHUB_USERNAME }}/oxker:${{env.CURRENT_SEMVER}} \
-t ghcr.io/${{ github.repository }}:latest \
-t ghcr.io/${{ github.repository }}:${{env.CURRENT_SEMVER}} \
--provenance=false --sbom=false \
--push \
-f containerised/Dockerfile .
########################
# Publish to crates.io #
########################
# This could be moved to before a github release is made
dry_run_cargo_publish:
runs-on: ubuntu-latest
needs: [container_image_build]
steps:
- name: update rust stable
run: rustup update stable
- uses: actions/checkout@v5
- name: Publish Dry Run
run: cargo publish --dry-run
cargo_publish:
environment: crates.io
runs-on: ubuntu-latest
needs: [dry_run_cargo_publish]
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
steps:
- name: update rust stable
run: rustup update stable
- uses: actions/checkout@v5
- name: Publish
run: cargo publish
-45
View File
@@ -1,45 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'oxker'",
"cargo": {
"args": [
"build",
"--bin=oxker",
"--package=oxker"
],
"filter": {
"name": "oxker",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'oxker'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=oxker",
"--package=oxker"
],
"filter": {
"name": "oxker",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}
-805
View File
@@ -1,805 +0,0 @@
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.13.0'>v0.13.0</a>
### 2026-02-23
*BREAKING CHANGES*
+ `log_scroll_forward`, `log_scroll_back` renamed to `scroll_forward`, `scroll_back`
+ Additional KeyMap entry, `inspect` defaults to `i`, enables Inspect mode
+ Docker Host priorities reordered, *should* now be, from high to low order, `--host` cli argument, `config.toml` `host` value, `DOCKER_HOST` env, [Docker library](https://github.com/fussybeaver/bollard) default setting.
+ `config.toml` `host` value is now commented out by default - this should help with invalid Docker connection errors and enable easy Podman support
### Chores
+ dependencies updated, [4658a8de](https://github.com/mrjackwills/oxker/commit/4658a8de264698b0c8092e1227f0683527219a0b), [8b5899ca](https://github.com/mrjackwills/oxker/commit/8b5899ca238bcbff32519b376b920cd7b7509809), [bebb687c](https://github.com/mrjackwills/oxker/commit/bebb687c59f3b408e69b23d2e68fa69f006a3231)
+ GitHub workflow updated, [a0aa7918](https://github.com/mrjackwills/oxker/commit/a0aa7918241ee8f702d6472c620287aa4be7d56c)
### Features
+ Network chart, closes [#79](https://github.com/mrjackwills/oxker/issues/79), [99fcb8fe](https://github.com/mrjackwills/oxker/commit/99fcb8fedf01599ec346b65d435d4c301a7a8851)
+ Inspect mode & help panel redesign, [ae7f3f4a](https://github.com/mrjackwills/oxker/commit/ae7f3f4a9472b451c37c0ab97b1756b41a3529f5)
+ set rust-version in Cargo.toml, closes [#77](https://github.com/mrjackwills/oxker/issues/77), [0763a102](https://github.com/mrjackwills/oxker/commit/0763a1024f44d98b8d9d65f57995da538e40963c)
### Fixes
+ Enable quit on Docker connect error screen, [5f942eb2](https://github.com/mrjackwills/oxker/commit/5f942eb2e963660bd7fe9d80fa7ba8a83754803a)
### Refactors
+ dead code removed, [3e31a2a6](https://github.com/mrjackwills/oxker/commit/3e31a2a6bc02d6ef75bd6cbc18568e82e60e1ee3)
+ docker data spawns, [cd943f67](https://github.com/mrjackwills/oxker/commit/cd943f67e465fff9726b40570a089301a4a8f534)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.12.0'>v0.12.0</a>
### 2025-09-28
### Chores
+ create_release.sh updated, [d4af754a](https://github.com/mrjackwills/oxker/commit/d4af754ad245540db60177f7b202b3c64519c961)
+ dependencies updated, [03599b46](https://github.com/mrjackwills/oxker/commit/03599b46657d38d0c9f25c2ccfd9510f2b98dd84), [aef0c950](https://github.com/mrjackwills/oxker/commit/aef0c9503e7045a256856aa887d8c8d7722b9936), [f0771eab](https://github.com/mrjackwills/oxker/commit/f0771eab5d07d141fe7a8997db650f0f65ffe0a7), [1596de86](https://github.com/mrjackwills/oxker/commit/1596de8681ad6c0a7832eb922dd2dc36ab30eb41)
+ GitHub workflow updated, [66dae5e6](https://github.com/mrjackwills/oxker/commit/66dae5e61ea294ac8ce134a6c32b27c04166b6eb)
### Docs
+ fix numerous typos, [618a43b5](https://github.com/mrjackwills/oxker/commit/618a43b501914fdf2659e171172ad180364cf87a)
### Features
+ *BREAKING CHANGE* - `scroll_down_many` & `scroll_up_many` removed, `scroll_down_one` `scroll_up_one` renamed `scroll_down`, `scroll_up`, see [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config), [52a04ec1](https://github.com/mrjackwills/oxker/commit/52a04ec1d0b9e4877e304f60a857ebc00f88b4fd)
+ log search feature, closes [#72](https://github.com/mrjackwills/oxker/issues/72). Use `#` button, remappable via `log_search_mode`, to enter log search mode. Case-sensitive by default, editable in `config.toml` with `log_search_case_sensitive` entry. Customise colours via `[colors.log_search]` entries, again see see [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config), [96d94696](https://github.com/mrjackwills/oxker/commit/96d9469623a7c90b79aa8d82abf587290343ad37), [a2316a9c](https://github.com/mrjackwills/oxker/commit/a2316a9cac270790920a1ebd1be6532d51aba77c)
+ `term` renamed `filter term`, tests updated, [487c3faf](https://github.com/mrjackwills/oxker/commit/487c3faf96f4c197c8b82644c02466ea40626a5e)
My 32-bit armhf armv6 hardware no longer seems to be able to run Docker. Future `oxker` releases won't be tested on real hardware but will continue to be built and published for armv6.
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.11.1'>v0.11.1</a>
### 2025-08-21
### Reverts
+ GitHub workflow reverted, [d1b69858](https://github.com/mrjackwills/oxker/commit/d1b69858c622636afbc46db7d37cf91e58d61212)
see [v0.11.0 release notes](https://github.com/mrjackwills/oxker/releases/tag/v0.11.0) for more information about v0.11 release
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.11.0'>v0.11.0</a>
### 2025-08-21
### Chores
+ Dependencies updated, [ced885e0](https://github.com/mrjackwills/oxker/commit/ced885e0128b6d5d3a3c7cb97d7e53bc2da64893), [f9b40ea0](https://github.com/mrjackwills/oxker/commit/f9b40ea03d0e70e235c28646ff3f9ebb468a904d)
+ Rust 1.89 linting, [79d19cee](https://github.com/mrjackwills/oxker/commit/79d19ceeb81ae60bc5562683e405d6e74e6f2578)
+ GitHub workflow updated, [08384200](https://github.com/mrjackwills/oxker/commit/08384200558fa1b9d378ea62ea832708caebaa91), [6573af1e](https://github.com/mrjackwills/oxker/commit/6573af1ed7d382a81c1305397e904066bb8395a8)
### Features
+ Horizontally scroll across logs. By default use `←` & `→` keys to traverse horizontally across the lines when logs panel selected. Updated `config.toml` with `log_scroll_forward` and `log_scroll_back` [c190f020](https://github.com/mrjackwills/oxker/commit/c190f0206cc55b8e45b8373f9be954e828c18b3b), [8939ac03](https://github.com/mrjackwills/oxker/commit/8939ac0345326633e794cc10a981a1f3c5c07549)
+ Force clear screen & redraw of UI. By default uses `f` key, `config.toml` updated with `force_redraw` [50edbc0c](https://github.com/mrjackwills/oxker/commit/50edbc0cc09db864835fe81a03cba8eadafe548b)
+ Increase scroll speed using the `ctrl` key in conjunction with a scroll key, `config.toml` updated with `scroll_modifier`. The next release will remove `scroll_down_many` & `scroll_down_up` keys, [c5bbffdb](https://github.com/mrjackwills/oxker/commit/c5bbffdb5f9e800951e4060aa6aee8e00db589aa)
### Refactors
+ remove macos cfg none-const functions, Zigbuild now uses Rust 1.87.0, [eb686e2c](https://github.com/mrjackwills/oxker/commit/eb686e2c952e04da74b3e12c0bfa015ec4615e1d)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.5'>v0.10.5</a>
### 2025-06-19
### Reverts
+ Bollard update rolled back, closes [#66](https://github.com/mrjackwills/oxker/issues/66), [aac9c6b5](https://github.com/mrjackwills/oxker/commit/aac9c6b598ce6c23b14f5a8b0116e662b18074d2)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.4'>v0.10.4</a>
### 2025-06-18
### Chores
+ .devcontainer updated, [324f8268](https://github.com/mrjackwills/oxker/commit/324f8268278081504d5357f2ed89b78ca2c25d04)
+ dependencies updated, [0ace9dd6](https://github.com/mrjackwills/oxker/commit/0ace9dd662144a589341779a64d7fcd8de7d9978), [a6360075](https://github.com/mrjackwills/oxker/commit/a636007547280b3b3db69374601dbece4bc21eef)
+ Rust 1.87.0 linting, [395b1aa7](https://github.com/mrjackwills/oxker/commit/395b1aa7e997a528e4f21e66f5f859001c1c3ec1), [67e5888e](https://github.com/mrjackwills/oxker/commit/67e5888e008cfd504c10e47f678f9351c838be99)
back
### Docs
+ example config files updated, [63ab7de7](https://github.com/mrjackwills/oxker/commit/63ab7de72897de460f31181c5a42befbee2f91d3), [8fb5ac4a](https://github.com/mrjackwills/oxker/commit/8fb5ac4a945b75f3fcd118c53be1202ccbc43c59)
+ README.md updated, link to directories crate, closes [#65](https://github.com/mrjackwills/oxker/issues/65), [c2bfe329](https://github.com/mrjackwills/oxker/commit/c2bfe3296563daf4b7f077469f3eeff6895720b0)
### Features
+ log panel size configurable, closes [#50](https://github.com/mrjackwills/oxker/issues/50), use the `-` or `=` keys to change the height of the logs panel, or `\` to toggle visibility. Automatically hide the logs panel using a new config item `show_logs`, see `example_config/*` files for more details, [6edf99e0](https://github.com/mrjackwills/oxker/commit/6edf99e0846bb4134d8ee5b646065b8cda8074d7)
+ build release binaries for aarch64-apple-darwin, closes [#62](https://github.com/mrjackwills/oxker/issues/62), personally untested on MacOS - but others suggest it works as expected, [e7114d2f](https://github.com/mrjackwills/oxker/commit/e7114d2f5e0ed8935943be64726fc2d90464a777), [2e850090](https://github.com/mrjackwills/oxker/commit/2e8500902a515a246f9d9a503b4350849d634978)
### Fixes
+ merge args color/raw fix, [d1983987](https://github.com/mrjackwills/oxker/commit/d198398795698a21d81d3fd20231c482cc346ab5)
### Refactors
+ reduce cloning of the logs text items, can expect 40-50% reduction in CPU and memory usage in certain common situations, [ecefa302](https://github.com/mrjackwills/oxker/commit/ecefa302b9ef5320ad4cce0b606aca70a7b459e2)
+ dead code removed, [b40b6b19](https://github.com/mrjackwills/oxker/commit/b40b6b197e4e5fbdab083bc918d1a5d2750597f3)
### Tests
+ add more whole layout tests, [4b81c6ca](https://github.com/mrjackwills/oxker/commit/4b81c6caaf12028d7527c3f23cd2de6d1503e223)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.3'>v0.10.3</a>
### 2025-04-22
### Chores
+ dependencies updated, [bbfd2462](https://github.com/mrjackwills/oxker/commit/bbfd2462a1f45008587b488e8c6049ee76da72f2)
### Tests
+ fix tests for MacOS, closes [#61](https://github.com/mrjackwills/oxker/issues/61), [cfc2decd](https://github.com/mrjackwills/oxker/commit/cfc2decd8d237f1ac3f0bdb2b3d5581684064448)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.2'>v0.10.2</a>
### 2025-04-19
### Chores
+ dependencies updated, [1345ecb1](https://github.com/mrjackwills/oxker/commit/1345ecb1a2b17ad3d288f2de2058c0777b84f93b)
### Tests
+ use a fixed version String, `0.00.000`, in tests, [230174b3](https://github.com/mrjackwills/oxker/commit/230174b3c327c3f217cdcf8fce07d5d9ddea1033)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.1'>v0.10.1</a>
### 2025-04-18
### Chores
+ dependencies updated, [8f959c54](https://github.com/mrjackwills/oxker/commit/8f959c5408995527485e817514c3f4a10bca31bd), [69d1801e](https://github.com/mrjackwills/oxker/commit/69d1801ea1e71e7d84c00fb2142ba27577e3bb73), [188490e1](https://github.com/mrjackwills/oxker/commit/188490e13fd1255eecb305ac0a99a7a1913e0294)
+ Rust 1.86.0 linting, [9acf6033](https://github.com/mrjackwills/oxker/commit/9acf60334c5224faa9ee4ecf7030b2e9b13b7d67)
### Docs
+ comment typo, [723b220c](https://github.com/mrjackwills/oxker/commit/723b220c6aa6393b8eebd84a6ddba69f35dc18b8)
### Fixes
+ github workflow update, [997eebca](https://github.com/mrjackwills/oxker/commit/997eebca20a2883dcfcb35009dd4d7b0d438dec6)
+ config merging, [a468827f](https://github.com/mrjackwills/oxker/commit/a468827f02c5243b9bd4e0183fed16854ae6c851)
### Refactors
+ rename ChartType to ChartVariant, [bca67116](https://github.com/mrjackwills/oxker/commit/bca67116f3b71451156a39a7b0957568b26fa183), [d0caa927](https://github.com/mrjackwills/oxker/commit/d0caa9271b6f92f52ccfe3dec69708efe54e5170)
+ rename FileType to FileFormat, [848f64d0](https://github.com/mrjackwills/oxker/commit/848f64d0da429e547a2f6c8de62e4da5f5c9a187)
### Tests
+ Use insta, closes [#57](https://github.com/mrjackwills/oxker/issues/57), [9362d7b4](https://github.com/mrjackwills/oxker/commit/9362d7b481ea22eab6f902dc7f3c10150c7ddf22)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.10.0'>v0.10.0</a>
### 2025-02-23
### Chores
+ dependencies updated, [e5f355a1](https://github.com/mrjackwills/oxker/commit/e5f355a1928f78abdb64e4c5617d6fac06340016), [4539d8ad](https://github.com/mrjackwills/oxker/commit/4539d8ad0705b46d7c89c51c7be482b696d26e5f), [6aee6181](https://github.com/mrjackwills/oxker/commit/6aee6181136235a1a4f79af9b9748c1801be8bf8), [64d1bdf2](https://github.com/mrjackwills/oxker/commit/64d1bdf2bf88407e02f0eded1e03fcfc5ee2d8e3)
+ .devcontainer dependencies updated, [5c8e76e7](https://github.com/mrjackwills/oxker/commit/5c8e76e7bb4d7aab8543c9be09fdbc4ffa446b10)
+ example docker-compose.yml updated, [2354b0b9](https://github.com/mrjackwills/oxker/commit/2354b0b9be1ab3795a421512594b2650b9cbdd74)
+ Rust 1.84 linting, [3065265e](https://github.com/mrjackwills/oxker/commit/3065265e26c30d78ba738cfe731d3901ec1948d0)
### Features
+ Config file introduced, including customizing color scheme of application, closes [#47](https://github.com/mrjackwills/oxker/issues/47), [f4d54e1b](https://github.com/mrjackwills/oxker/commit/f4d54e1ba8ea1516394aef19511a63e6271f27bf)
+ Enable log timestamps to be set to any given timezone, plus custom timestamp format via the config file, closes [#56](https://github.com/mrjackwills/oxker/issues/56), [7a5e7a25873d2c270e5808730721ebb5427a051]
+ update Rust edition to 2024, [7e4a960b](https://github.com/mrjackwills/oxker/commit/7e4a960b888f1dab524d6045504162cea1171d20)
### Fixes
+ Only draw screen if data or layout has changed, drastically reduces CPU usage, [bfc295c5](https://github.com/mrjackwills/oxker/commit/bfc295c50e982886ccaa5e60b57f10d3690b3f09)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.9.0'>v0.9.0</a>
### 2024-12-05
### Chores
+ dependencies updated, [b7871357](https://github.com/mrjackwills/oxker/commit/b78713579c4706d605e5b35fcd832610a0152294), [c6200e8f](https://github.com/mrjackwills/oxker/commit/c6200e8f77f8bb1f0152cb9374029d15cc45df9d)
+ Rust 1.83 linting, [751d997a](https://github.com/mrjackwills/oxker/commit/751d997a3dac823e144ae62e6c1455676e50ddb8)
### Features
+ `--no-stderr` cli arg, removes Standard error output from logs, closes [#52](https://github.com/mrjackwills/oxker/issues/52), [c739637b](https://github.com/mrjackwills/oxker/commit/c739637b91c8fa742a69f4d888678d7b3964678c)
+ ContainerPorts use ipaddr, [1b26997d](https://github.com/mrjackwills/oxker/commit/1b26997d25f748e0d452f41fe41791533046ecdf)
### Fixes
+ update containerised Dockerfile, [0c6f5322](https://github.com/mrjackwills/oxker/commit/0c6f53228f01196e352c2069383ba1e7a10950a8)
+ calculate_usage overflow, [5106a01f](https://github.com/mrjackwills/oxker/commit/5106a01f3dcb87ce5a8f1fb7bf49dc6b3c25d03e)
+ DockerData spawns insertion error, [d4906d33](https://github.com/mrjackwills/oxker/commit/d4906d33c26b75d92e7d80040c488faa90a257c6)
### Refactors
+ speed up docker logs init process, [8b9fe424](https://github.com/mrjackwills/oxker/commit/8b9fe4246865441704ae12dff0938868a4fe6f81)
+ remove docker sleep, [f1562d10](https://github.com/mrjackwills/oxker/commit/f1562d1084336fe5be39894c93cb49107f0a4a6d)
+ dead code removed, [5ee48d57](https://github.com/mrjackwills/oxker/commit/5ee48d5708fa6de0206c021db0bb611196e66fba), [ba6a9524](https://github.com/mrjackwills/oxker/commit/ba6a95241389f99d504ee4bf3e87e19006f12e49), [f0b11456](https://github.com/mrjackwills/oxker/commit/f0b1145651625ad4e577d79baaf902d4d3bc0579)
+ input_handler, [7f423834](https://github.com/mrjackwills/oxker/commit/7f4238349525c01ae9fb8b1f6c0946e5364dd55e)
+ statefulList get_state_title, [2d540b0e](https://github.com/mrjackwills/oxker/commit/2d540b0e2210cc04d73035ec59211ffc739174f6)
+ statefulList next/previous, [7bb2bef2](https://github.com/mrjackwills/oxker/commit/7bb2bef28d90ebc58da86a0365a1904a0c32dffe)
+ help_box closure fn, [2860426d](https://github.com/mrjackwills/oxker/commit/2860426d57a4458fcee49a2fd20e8e7bb9e71fb5)
+ use check_sub for sleep calculations, [fe3696e5](https://github.com/mrjackwills/oxker/commit/fe3696e5576739d8b033d9e748b5ea696c4b4e4f)
+ rename scheduler to heartbeat, [68a6551e](https://github.com/mrjackwills/oxker/commit/68a6551ed038a36330b2f098112829465a1c3c7a)
+ remove unnecessary is_running load, [76ccf7c0](https://github.com/mrjackwills/oxker/commit/76ccf7c00691f815c3ab0bede838c99252ba84f0)
+ execute_command(), [2a834d6c](https://github.com/mrjackwills/oxker/commit/2a834d6c2fa4a15124d24ddbd12f667829e148ad)
+ Remove numerous clones(), [e5927f78](https://github.com/mrjackwills/oxker/commit/e5927f781a7e9517b9fa00a2d1a835d2774a9d26)
+ remove app_data param from generate_lock(), [1a8dab65](https://github.com/mrjackwills/oxker/commit/1a8dab654a1fdbf351a72dc54fe3d1943355bba6)
+ combine get_filter methods, [356ea554](https://github.com/mrjackwills/oxker/commit/356ea5549bb4877e9893fe0e1053e73c5a62e806)
+ FrameData refactors, [57781701](https://github.com/mrjackwills/oxker/commit/57781701ff14c553dfbafb965ee8a33ab44dd36f), [6e2f82db](https://github.com/mrjackwills/oxker/commit/6e2f82db81caaa98ce4781fa15928eb9e246ace6)
+ update_container_stat combine is_alive(), [55cc7467](https://github.com/mrjackwills/oxker/commit/55cc746736f6863aedc5ad838744a983796244d8)
+ remove `input_poll_rate` from `Ui`, instead use const `POLL_RATE`, [69f6c96b](https://github.com/mrjackwills/oxker/commit/69f6c96b700b9fde5578ae204992a67986d456ab)
+ pass `&FrameDate` into `draw_frame()`, [35aec506](https://github.com/mrjackwills/oxker/commit/35aec5060fdbe606267be26656b4aeee43d50c02)
+ dead code removed, [caf23be4](https://github.com/mrjackwills/oxker/commit/caf23be4a7faff99aaca80b081a02e4e0a372009)
+ input_handler, [9c4f8910](https://github.com/mrjackwills/oxker/commit/9c4f8910381b90b563da12eaba4b79cb60c40129)
+ draw_block, [de76bc22](https://github.com/mrjackwills/oxker/commit/de76bc22936b124dcb9646f302f6cc14691dbb63)
### Tests
+ fix logs tests, [9b22f5da](https://github.com/mrjackwills/oxker/commit/9b22f5da18e4bf92766a68a7f4cd61ad72724cfd)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.8.0'>v0.8.0</a>
### 2024-10-22
### Chores
+ dependencies updated, [ea877d23](https://github.com/mrjackwills/oxker/commit/ea877d23711b98ffd1108a74206d93d43482d44d), [af609c0d](https://github.com/mrjackwills/oxker/commit/af609c0dbf0caab4a073f822166de34999afb41b)
+ .devcontainer updated, [a9844436](https://github.com/mrjackwills/oxker/commit/a9844436d003b84a3e9d8b600ea029b232566f3a)
+ create_release.sh updated, [c4943370](https://github.com/mrjackwills/oxker/commit/c4943370f4a67f6c01c75a8a7f825912427666a2), [1389d8ad](https://github.com/mrjackwills/oxker/commit/1389d8adbba75fef480eb1de09337eb7beb10ba3)
### Features
+ Add Stderr output to logs, thanks [vincentmasse](https://github.com/vincentmasse), closes [#48](https://github.com/mrjackwills/oxker/issues/48), merges [#49](https://github.com/mrjackwills/oxker/pull/49), [b95c9311](https://github.com/mrjackwills/oxker/commit/b95c9311416cd0dbcfa5de90c23f3065bc2d6b17), [9936ad45](https://github.com/mrjackwills/oxker/commit/9936ad45e186ee431aade920674a2dc283937355), [289ede3f](https://github.com/mrjackwills/oxker/commit/289ede3f2531feeec56094a76bf34f4c69431bbe)
### Refactors
+ Rust 1.82 linting, [c058c5a3](https://github.com/mrjackwills/oxker/commit/c058c5a301cfd4e8d7a0079c4c3f8fdeae2803e5)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.7.2'>v0.7.2</a>
### 2024-09-07
### Reverts
+ Expect lint was causing issues with crates/docker builds, revert until fix is found, [578ed9f0](https://github.com/mrjackwills/oxker/commit/578ed9f085df0d97c451c06dab4ecbfccd894c52)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.7.1'>v0.7.1</a>
### 2024-09-07
### Chores
+ dependencies updated, [d6238587](https://github.com/mrjackwills/oxker/commit/d6238587ffc536f1ea93a47dd4d2ee69c36f35e3), [a564ef80](https://github.com/mrjackwills/oxker/commit/a564ef80318adbde9f188dd2cf38626a98793c75), [1d82ff13](https://github.com/mrjackwills/oxker/commit/1d82ff1368f7b43e6df8478d5e7b8d682320010a), [99f05f2e](https://github.com/mrjackwills/oxker/commit/99f05f2e5b511d039804159c92ade6c77ff360b7)
+ Rust 1.81.0 linting, [372f759c](https://github.com/mrjackwills/oxker/commit/372f759ca467e47c373a086c6a247c150b87d4bc)
+ .devcontainer updated, [5d77f1e0](https://github.com/mrjackwills/oxker/commit/5d77f1e02a428b4e5ee1bf5466055ad17f6e05af)
### Docs
+ CHANGELOG.md duplicate removed, [16ecc5a5](https://github.com/mrjackwills/oxker/commit/16ecc5a51f7defcc9dd4f4c6e34fa5bbfc7fea78)
+ Readme raspberry pi fix, [baf68783](https://github.com/mrjackwills/oxker/commit/baf68783929e5d6ac111a39dc62388cd24133da6)
+ Add installation guide to README for macOS installation via `brew install oxker`, thanks [miketheman](https://github.com/miketheman), [59817311](https://github.com/mrjackwills/oxker/commit/59817311baea628d2691765ff9387e055dce3307), [895ec620](https://github.com/mrjackwills/oxker/commit/895ec6204cc8220be64be7622df6edb6ef6d795b)
### Refactors
+ switch lints from `allow(x)` to `expect(x)`, [2a0ab6d8](https://github.com/mrjackwills/oxker/commit/2a0ab6d81ce4062de053a92b85f8b25ea23412b6)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.7.0'>v0.7.0</a>
### 2024-08-01
### Chores
+ .devcontainer extensions updated, [0288cbc8](https://github.com/mrjackwills/oxker/commit/0288cbc8146cde1dd40ceaec9550198b635bb8f5)
+ dependencies updated, [1df4f78d](https://github.com/mrjackwills/oxker/commit/1df4f78dc41013c33d901925933b1ccb29ad4bc8), [5ae253b8](https://github.com/mrjackwills/oxker/commit/5ae253b8734ba0495e4e8149b17d5228b3d86f8d), [7a517db9](https://github.com/mrjackwills/oxker/commit/7a517db9f7c14c35e56ff70cf76ffb608fd30e17), [9c291cd9](https://github.com/mrjackwills/oxker/commit/9c291cd9c81b6d9a02085878588ed3b845fd0046), [0e90f4eb](https://github.com/mrjackwills/oxker/commit/0e90f4eb55ac5fb5d45e7d212c3686027dd3913e), [fe71cbfb](https://github.com/mrjackwills/oxker/commit/fe71cbfb00f166b7c02a6e28e64650ed1b47d15d)
+ docker-compose alpine version bump, [51ceab3e](https://github.com/mrjackwills/oxker/commit/51ceab3ebdb09356cd401d2f268840239255126f)
+ Rust 1.80 linting, [93e1279b](https://github.com/mrjackwills/oxker/commit/93e1279b1fc77019442a385e2e36be2fe438e828)
+ create_release v0.5.6, [f408acfe](https://github.com/mrjackwills/oxker/commit/f408acfe9a9f5a976735b8a8a51500fd7b865daf)
### Docs
+ screenshot updated, [6975ebe7](https://github.com/mrjackwills/oxker/commit/6975ebe70f7058229c232e4a56b090f55247d2a2)
### Features
+ left align all text, [e0d421c4](https://github.com/mrjackwills/oxker/commit/e0d421c4918a17c9e0e21fd214edb99d71281c9d)
+ place image name in logs panel title, [12f24357](https://github.com/mrjackwills/oxker/commit/12f24357a68abe871f44d871d95b6e2ef062181e)
+ distinguish between unhealthy & healthy running containers, closes [#43](https://github.com/mrjackwills/oxker/issues/43), [de876818](https://github.com/mrjackwills/oxker/commit/de8768181631c6d961ce0e4dacb50c2ed02abc36)
+ filter containers, use `F1` or `/` to enter filter mode, closes [#37](https://github.com/mrjackwills/oxker/issues/37), thanks to [MohammadShabaniSBU](https://github.com/MohammadShabaniSBU) for the original PR, [d5d8a0db](https://github.com/mrjackwills/oxker/commit/d5d8a0dbc5437ff3b17f34b9dbb9589bb56b4a3e), [7ee1f06f](https://github.com/mrjackwills/oxker/commit/7ee1f06f804683e3395953a02138d4e9da115ea9)
### Fixes
+ log_sanitizer `raw()` & `remove_ansi()` now functioning as intended, [0dc98dfc](https://github.com/mrjackwills/oxker/commit/0dc98dfc8113869b81be9d697ca77418c919e4bf)
+ Dockerfile command use uppercase, [068e4025](https://github.com/mrjackwills/oxker/commit/068e4025a5d6049a9a6951a0480a6bdef7379f88)
+ heading section help margin, [0e927aae](https://github.com/mrjackwills/oxker/commit/0e927aae178c1d8f60561b93607a26d45a1d9331)
+ install.sh use curl, [197a031b](https://github.com/mrjackwills/oxker/commit/197a031b8cf356f49f08e04472d0d1c489699415)
### Tests
+ fix layout tests with new left alignment, [dfced564](https://github.com/mrjackwills/oxker/commit/dfced564278eafdbb8a5b95badbae3a7c4bf87b3)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.4'>v0.6.4</a>
### 2024-05-25
### Chores
+ Dependencies updated, [51fdd26b](https://github.com/mrjackwills/oxker/commit/51fdd26be5b3166bcff5c26ece6d6ec0d893381e), [c1be658b](https://github.com/mrjackwills/oxker/commit/c1be658b8cc4786a9a7f2e0a88568019b3995c14)
### Docs
+ exec mode "not available on Windows", in both README.md and help panel, [df449a85](https://github.com/mrjackwills/oxker/commit/df449a85376bbeec87215952d6a9196721f7132e)
### Fixes
+ closes [#36](https://github.com/mrjackwills/oxker/issues/36) Double key strokes on Windows, [9b7d575a](https://github.com/mrjackwills/oxker/commit/9b7d575a76398cbe19e17f6494baf802dbb512b9)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.3'>v0.6.3</a>
### 2024-05-07
### Chores
+ Dependencies updated, [07e293ac](https://github.com/mrjackwills/oxker/commit/07e293ac2ce2e7deb5735154fcdb24ef83a19b67), [27d72c54](https://github.com/mrjackwills/oxker/commit/27d72c547e738f6816cd4b353ac881e454a0be70)
### Features
+ Allow closing dialogs with `Escape`, thanks [JCQuintas](https://github.com/JCQuintas), [0e4c3cea](https://github.com/mrjackwills/oxker/commit/0e4c3ceab933458d40b54d5fcff7e6cf7a3ab315)
### Fixes
+ correct header display when terminal width changes, [4628803b](https://github.com/mrjackwills/oxker/commit/4628803b2b9fe63522d033b192763ed6ff5b57dd)
### Refactors
+ use tokio CancellationToken, [0631a73e](https://github.com/mrjackwills/oxker/commit/0631a73ec27530f8fcc88988a0a02ca75e32c5ba)
+ impl AsyncTTY, [bf33776e](https://github.com/mrjackwills/oxker/commit/bf33776e9a61684032a80d22d995ba7e0446620e)
### Tests
+ reduced header section test, [aa094740](https://github.com/mrjackwills/oxker/commit/aa0947405393db2c306e86986183514cbc0f5a75)
+ test_draw_blocks_help() with add esc text, [ff839af4](https://github.com/mrjackwills/oxker/commit/ff839af4ef68193149d6456e70fee189228c4a44)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.2'>v0.6.2</a>
### 2024-03-31
### Chores
+ .devcontainer updated, [82a7f84e](https://github.com/mrjackwills/oxker/commit/82a7f84ed94a9bad6cc5fe078bfadbda65c8ea8f)
+ Rust 1.77 linting, [dfd4948d](https://github.com/mrjackwills/oxker/commit/dfd4948d9c43cfbffb091c303d3cb2a05522047a)
+ platform.sh formatted, [7953e68f](https://github.com/mrjackwills/oxker/commit/7953e68f3067ac3c9d4fe67e9f934c25036c0833)
+ dependencies updated, [8e6c3ca6](https://github.com/mrjackwills/oxker/commit/8e6c3ca6d83768f043923ccf6836397beaaae639)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.1'>v0.6.1</a>
### 2024-02-14
### Chores
+ create_release v0.5.5, [616338b7](https://github.com/mrjackwills/oxker/commit/616338b7107036e968f51c3ff80739f9ffb40fbd)
+ update dependencies, [10180d2e](https://github.com/mrjackwills/oxker/commit/10180d2e0817c00a198e27f7d71080c502639a6b)
+ update to ratatui v0.26.0, [d33dce3e](https://github.com/mrjackwills/oxker/commit/d33dce3eec4c19cc3c3668dab77f7d25d6970c3c)
+ GitHub workflow dependency bump, [0314eac9](https://github.com/mrjackwills/oxker/commit/0314eac9df6cf9fea1943dcd06bd6a0b27131c16)
### Docs
+ screenshot updated, [fe5ec4f5](https://github.com/mrjackwills/oxker/commit/fe5ec4f5dd25f11817be37f3f1867a6a2b0afc42)
### Fixes
+ ports all listed in white, [d3b23585](https://github.com/mrjackwills/oxker/commit/d3b23585b38045eb3bc827367eca90eb7f7a7dd5)
+ use long container name in delete popup, [6202b7bb](https://github.com/mrjackwills/oxker/commit/6202b7bbfdfb04a94959b5143dac3f1aa59cd336)
+ memory display, closes [#33](https://github.com/mrjackwills/oxker/issues/33), [a182d40a](https://github.com/mrjackwills/oxker/commit/a182d40a7463164ef5dcac379d1a1768d77209d2)
### Refactors
+ use &[T] instead of &Vec<T>, [76cd08ab](https://github.com/mrjackwills/oxker/commit/76cd08ab2f98687a866a6bbb4fa93bbdedaa7699), [1f62bb50](https://github.com/mrjackwills/oxker/commit/1f62bb50210f2d66bb7215e42e8b21a3c1a6ec06)
+ draw_block constraints into consts, [0436ff1b](https://github.com/mrjackwills/oxker/commit/0436ff1b7356c80532048c7d497c66d331092b01)
### Tests
+ update port test with new colour, [f74ae3f5](https://github.com/mrjackwills/oxker/commit/f74ae3f5c34d74b78822078291fed401427c4cba)
+ color match tests updated, [5b287416](https://github.com/mrjackwills/oxker/commit/5b287416315942b19c62f8c66348ce28462d894c)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.6.0'>v0.6.0</a>
### 2024-01-18
### Chores
+ dependencies updated, [53b4bafb](https://github.com/mrjackwills/oxker/commit/53b4bafbe53312fe41608ddf33e865d474222aaa), [58ef1516](https://github.com/mrjackwills/oxker/commit/58ef151600e362048a607c8ae61a5edfe80ab1dd), [b6fd3502](https://github.com/mrjackwills/oxker/commit/b6fd35022a99ec0e982ddb154b0450d49c4840e9), [0438c108](https://github.com/mrjackwills/oxker/commit/0438c108bdd9815d7eae1b89c47c4e6438f358d6)
+ files formatted, [1806165c](https://github.com/mrjackwills/oxker/commit/1806165c3e266876b2d1806f7b662d09705f3aad)
+ create_release.sh check for unused lint, [d0b27211](https://github.com/mrjackwills/oxker/commit/d0b27211928f93f8455e1ee5a6a6485c6a21d382)
### Docs
= Readme updated, screenshot added, [7561a934](https://github.com/mrjackwills/oxker/commit/7561a93415c1e1f596b15edba95e7b32a939cd90), [4069e557](https://github.com/mrjackwills/oxker/commit/4069e5572f81cb689dbb9f735db919e4636cdccc)
### Features
+ Ports section added, closes [#21](https://github.com/mrjackwills/oxker/issues/21), [65a1afcb](https://github.com/mrjackwills/oxker/commit/65a1afcb0605604ede350a5630c775f94ebb74ee), [7a096a65](https://github.com/mrjackwills/oxker/commit/7a096a65c40924021fe643fe0aa1067095832df9)
### Fixes
+ sort arrow now on left of header, [40ddcb72](https://github.com/mrjackwills/oxker/commit/40ddcb727d2c1758d6dd26a58507b85b219f51e2)
### Refactors
+ rename string_wrapper > unit_struct, [27cf53e4](https://github.com/mrjackwills/oxker/commit/27cf53e41f8b379f606c1c27620ee08e79bac57e)
### Tests
+ Finally have tests, currently for layout and associated methods, at the moment running the tests will not interfere with any running Docker containers, [4bcf77db](https://github.com/mrjackwills/oxker/commit/4bcf77db776a36e0a8151ecfbda722a66c4ba46c)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.5.0'>v0.5.0</a>
### 2024-01-05
### Chores
+ .devcontainer updated, [2313618e](https://github.com/mrjackwills/oxker/commit/2313618eb1493ce41d70847b888c32b65fdc40ea), [5af6b8bc](https://github.com/mrjackwills/oxker/commit/5af6b8bcd31c3c38ff5a5799c76dc1cbe1167763), [9b0b6b10](https://github.com/mrjackwills/oxker/commit/9b0b6b10c3a0c1d5095490cfd3cda18d252f38f5)
+ alpine version bump, [061de032](https://github.com/mrjackwills/oxker/commit/061de032dad935c56c6caab419ecb5c9bbac4c7e)
+ dependencies updated, [0890991f](https://github.com/mrjackwills/oxker/commit/0890991ff1a239fe2d556a0c4eac6ae05beb9b50), [0a7b266b](https://github.com/mrjackwills/oxker/commit/0a7b266b2a358a4788ae877ca8a97f08eac4eef2), [333621f1](https://github.com/mrjackwills/oxker/commit/333621f1a7321c1fdf73fd35dd7f3ab165a9dc64), [3e51889c](https://github.com/mrjackwills/oxker/commit/3e51889cd8a552b1da463ae6a40d5de6eec188f5), [a179bb6f](https://github.com/mrjackwills/oxker/commit/a179bb6f6a7e076269fa830f56c0d4a31cf8488a)
+ file formatting, [eb5e74ae](https://github.com/mrjackwills/oxker/commit/eb5e74ae67d815bf49f241d2baf319e41cf9adf8)
+ Rust 1.75.0 linting, [81be75f2](https://github.com/mrjackwills/oxker/commit/81be75f27fd32a59ebff57e44c5022ff862df84b)
### Docs
+ screenshot updated, [0231d1bd](https://github.com/mrjackwills/oxker/commit/0231d1bdcda304300d289243a95044ab3bdce85c)
+ comment typo, [0ad1ec9d](https://github.com/mrjackwills/oxker/commit/0ad1ec9d85d6f0cac743b4421d0ad03432c9d717)
### Features
+ re-arrange columns, container name is now the first column, added a ContainerName & ContainerImage struct via `string_wrapper` macro, closes [#32](https://github.com/mrjackwills/oxker/issues/32), [e936bb4b](https://github.com/mrjackwills/oxker/commit/e936bb4b78980d0e34a1ef5e9f6f82a9ed0ddc7f)
### Fixes
+ Docker Commands hidden, [4301e470](https://github.com/mrjackwills/oxker/commit/4301e4709f99fc23ee438bf345b0dc698a05dc4e)
+ .gitattributes, [1234ea53](https://github.com/mrjackwills/oxker/commit/1234ea53897b2ed6ada0eb18cd81b8783a5dc5f5)
### Refactors
+ GitHub workflow action improved, [04b66af2](https://github.com/mrjackwills/oxker/commit/04b66af2b60c96cfbece0b13109e30b08ef35cc4)
+ sort_containers, [ccf8b55a](https://github.com/mrjackwills/oxker/commit/ccf8b55a7495982f72b4fb3af6e11a9bd7465216)
+ string_wrapper .get() return `&str`, [a722731c](https://github.com/mrjackwills/oxker/commit/a722731c6a77e00d1fb13967b51400aa34e72213)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.4.0'>v0.4.0</a>
### 2023-11-21
### Chores
+ workflow dependencies updated, [6a4cf649](https://github.com/mrjackwills/oxker/commit/6a4cf6490d08b976734e2bc8186d94c095700558)
+ dependencies updated, [e301b518](https://github.com/mrjackwills/oxker/commit/e301b51891e03ea40b2f904583119da3bc4daf53), [81d5b326](https://github.com/mrjackwills/oxker/commit/81d5b326db8881263f2c9072e1426948e41b4a0f), [294cc268](https://github.com/mrjackwills/oxker/commit/294cc2684f42daab9d51601e235a384f55617678)
+ lints moved from main.rs to Cargo.toml, [2de76e2f](https://github.com/mrjackwills/oxker/commit/2de76e2f358be9c1500ca3dc4f9df0979ed8ed28)
+ .devcontainer updated, [37d2ee91](https://github.com/mrjackwills/oxker/commit/37d2ee915625806dd11c2cc816a892aae12a777c)
### Features
+ Docker exec mode - you are now able to attempt to exec into a container by pressing the `e` key, closes [#28](https://github.com/mrjackwills/oxker/issues/28), [c8077bca](https://github.com/mrjackwills/oxker/commit/c8077bca0b673478cfbb417e677a885136ba9eff), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b)
+ Export logs feature, press `s` to save logs, use `--save-dir` cli-arg to customise output location, closes [#1](https://github.com/mrjackwills/oxker/issues/1), [a15da5ed](https://github.com/mrjackwills/oxker/commit/a15da5ed43d07852504a4dd1884a189e3f5b9d84)
### Fixes
+ GitHub workflow, cargo publish before create release, [ae4ce3b5](https://github.com/mrjackwills/oxker/commit/ae4ce3b549c40cc8bd713f375f030b185179a6e2)
+ sorted created_at clash, closes [#22](https://github.com/mrjackwills/oxker/issues/22), [3a648939](https://github.com/mrjackwills/oxker/commit/3a6489396e87702ce94b349a7f47028ece7922f6)
+ `as_ref()` fixed, thanks [Daniel-Boll](https://github.com/Daniel-Boll), [77fbaa8b](https://github.com/mrjackwills/oxker/commit/77fbaa8b1669286369b6ec1edd80220c808b628f)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.3.3'>v0.3.3</a>
### 2023-10-21
### Chores
+ docker-compose Alpine bump, [d46c425f](https://github.com/mrjackwills/oxker/commit/d46c425fa29f3c1d27bd57764748bae7e0b82f69)
+ dependencies updated, [e6eecbbd](https://github.com/mrjackwills/oxker/commit/e6eecbbdce9c0ccff42aa8806dddb6e3364f990c), [ec93115e](https://github.com/mrjackwills/oxker/commit/ec93115ece83002fa127f3358f573319e29357e1), [b36daa5a](https://github.com/mrjackwills/oxker/commit/b36daa5aeaa354b6c4f45b7ae67ac1a6345ea1c0), [9c0de1f0](https://github.com/mrjackwills/oxker/commit/9c0de1f0feff3165d0f5b6cb5dda843c124bcfa4), [6dd953df](https://github.com/mrjackwills/oxker/commit/6dd953df458096aee5914411ce40e46c3f600ede)
+ Rust 1.73 linting, [21234c66](https://github.com/mrjackwills/oxker/commit/21234c66c3935330ccd58543dd3a915a293ac776)
### Docs
+ README.md updated, [3fd3915b](https://github.com/mrjackwills/oxker/commit/3fd3915b3e929742d8007109fd4c7b4a345eb0fa)
### Refactors
+ LogsTZ from `&str`, [44f581f5](https://github.com/mrjackwills/oxker/commit/44f581f5b3652cc4e623fe145141878754dca292)
+ from string impl, [ca79893d](https://github.com/mrjackwills/oxker/commit/ca79893df5f05ebf445ce194d578cb8213c9755e)
+ env handling, [18c3ed43](https://github.com/mrjackwills/oxker/commit/18c3ed43376a8b5e2d285d1b34a9f96843357d53)
+ `parse_args/mod.rs` > `parse_args.rs`, [a6ff4124](https://github.com/mrjackwills/oxker/commit/a6ff4124319ed17d3f1c46c916418f850ef1d3b0)
+ set_info_box take `&str`, [faeaca0c](https://github.com/mrjackwills/oxker/commit/faeaca0cd1bb243c7f4a7112b928be776b877ca1)
+ GitHub action use concurrency matrix, re-roder workflow, [85f1982f](https://github.com/mrjackwills/oxker/commit/85f1982f4066bfdbc764ab7b88588eded6a17f96)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.3.2'>v0.3.2</a>
### 2023-08-28
### Chores
+ dependencies updated, [8ce5a187](https://github.com/mrjackwills/oxker/commit/8ce5a1877a8c56d9bbab560c97e2596ea87cc4c0), [94a20584](https://github.com/mrjackwills/oxker/commit/94a20584e6ef0701c9f36838b0dfbcd911698dbe), [29e02e0d](https://github.com/mrjackwills/oxker/commit/29e02e0d1faae4a836c7e5cfd0d791338ff586e3), [8e4c2e68](https://github.com/mrjackwills/oxker/commit/8e4c2e686761df56920df2267b765ab1297c9972)
+ `_typos.toml` added, [84ba1020](https://github.com/mrjackwills/oxker/commit/84ba1020939606abf4a287cbd1de1f3a10d3f0c0)
### Features
+ Custom hostname. `oxker` will use `$DOCKER_HOST` env if set, or one can use the cli argument `--host`, which takes priority over the `$DOCKER_HOST`, closes [#30](https://github.com/mrjackwills/oxker/issues/30), [10950787](https://github.com/mrjackwills/oxker/commit/10950787649d2b66fc1e8cd8b85526df51479857)
### Refactors
+ `set_error()` takes `gui_state` and error enum, to make sure app_data & gui_state is in sync [62c78dfa](https://github.com/mrjackwills/oxker/commit/62c78dfaa50a8d8c084f7fbf7e203b50aaa731ae)
+ `fn loading_spin` doesn't need to be async, [2e27462d](https://github.com/mrjackwills/oxker/commit/2e27462d1b3f0bdb27d7646511e36d0c9af07f3e)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.3.1'>v0.3.1</a>
### 2023-06-04
### Chores
+ github workflow ubuntu latest, build for x86 musl, [4fa841e6](https://github.com/mrjackwills/oxker/commit/4fa841e6e74e3e10e3d3e82eac1a1ca1338814cf)
+ dependencies updated, [0caa92f6](https://github.com/mrjackwills/oxker/commit/0caa92f6a4728d50d8b2d8f15d96a21112732ec5), [1fd1dfc7](https://github.com/mrjackwills/oxker/commit/1fd1dfc75d6fa4e84451ebc845b9e1c730381f41)
+ `Spans` -> `Line`, ratatui 0.21 update, [4679ddc8](https://github.com/mrjackwills/oxker/commit/4679ddc885a9b35c901f3600b63fd9e86118264c), [0d37ac55](https://github.com/mrjackwills/oxker/commit/0d37ac55018038363e5f92dc4215996f8cff7b2e)
+ `create_release.sh` updated, [7dec5f14](https://github.com/mrjackwills/oxker/commit/7dec5f14a381d237c5e72fbf9551bcf398f93f3e)
### Fixes
+ workflow additional image fix, closes [#29](https://github.com/mrjackwills/oxker/issues/29), [47cda44b](https://github.com/mrjackwills/oxker/commit/47cda44b8213cfb8c3807df6c43e3f5dc2452b57)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.3.0'>v0.3.0</a>
### 2023-03-30
### Chores
+ dependencies updated, [7a9bdc96](https://github.com/mrjackwills/oxker/commit/7a9bdc9699594532e17a33e044ca0678693c8d3f), [58e03a75](https://github.com/mrjackwills/oxker/commit/58e03a750fe89b914b9069cb0c6c02a3d0929439), [b246e8c2](https://github.com/mrjackwills/oxker/commit/b246e8c25af0c5136953afca7c694cda66550d9b)
### Docs
+ README.md and screenshot updated, [73ab7580](https://github.com/mrjackwills/oxker/commit/73ab7580c61dd59c59f10872629111360afb9033)
### Features
+ Ability to delete a container, be warned, as this will force delete, closes [#27](https://github.com/mrjackwills/oxker/issues/27), [937202fe](https://github.com/mrjackwills/oxker/commit/937202fe34d1692693c62dd1a7ad19db37651233), [b25f8b18](https://github.com/mrjackwills/oxker/commit/b25f8b18f4f2acd5c9af4a1d40655761d1bd720e)
+ Publish images to `ghcr.io` as well as Docker Hub, and correctly tag images with `latest` and the current sermver, [cb1271cf](https://github.com/mrjackwills/oxker/commit/cb1271cf7f21c898020481ad85914a3dcc83ec93)
+ Replace `tui-rs` with [ratatui](https://github.com/tui-rs-revival/ratatui), [d431f850](https://github.com/mrjackwills/oxker/commit/d431f850219b28af2bc45f3b6917377604596a40)
### Fixes
+ out of bound bug in `heading_bar()`, [b9c125da](https://github.com/mrjackwills/oxker/commit/b9c125da46fe0eb4aae15c354d87ac824e9cb83a)
+ `-d` arg error text updated, [e0b49be8](https://github.com/mrjackwills/oxker/commit/e0b49be84062abdfcb636418f57043fad37d06ec)
### Refactors
+ `popup()` use `saturating_x()` rather than `checked_x()`, [d628e802](https://github.com/mrjackwills/oxker/commit/d628e8029942916053b3b7e72d363b1290fc5711)
+ button_item() include brackets, [7c92ffef](https://github.com/mrjackwills/oxker/commit/7c92ffef7da20143a31706a310b5e6f2c3e0554f)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.5'>v0.2.5</a>
### 2023-03-13
### Chores
+ Rust 1.68.0 clippy linting, [5582c454](https://github.com/mrjackwills/oxker/commit/5582c45403413d3355bbcd629cfad559296f5e5b)
+ devcontainer use sparse protocol index, [20b79e9c](https://github.com/mrjackwills/oxker/commit/20b79e9cd5bf75bb253158c0b590284139e0291d)
+ dependencies updated, [0c07d4b4](https://github.com/mrjackwills/oxker/commit/0c07d4b40607a0eba003b6dcd0345ec0543c6264), [601a73d2](https://github.com/mrjackwills/oxker/commit/601a73d2c830043a25d64922c4d4aa38f8801912), [5aaa3c1a](https://github.com/mrjackwills/oxker/commit/5aaa3c1ab08b0c85df9bfce18a3e60206556fa58), [7a156303](https://github.com/mrjackwills/oxker/commit/7a1563030e48499da7f41033673c70deefe3de8a), [45715775](https://github.com/mrjackwills/oxker/commit/457157755baa1f9e9cfef9315a7940c357b0953d)
### Features
+ increase mpsc channel size from 16 to 32 messages, [924f14e9](https://github.com/mrjackwills/oxker/commit/924f14e998f79f731447a2eded038eab51f2e932)
+ KeyEvents send modifier, so can quit on `ctrl + c`, [598f67c6](https://github.com/mrjackwills/oxker/commit/598f67c6f6a8713102bcc415f0409911763bb914)
+ only send relevant mouse events to input handler, [507660d8](https://github.com/mrjackwills/oxker/commit/507660d835d0beaa8cd021110401ecc58c0613c6)
### Fixes
+ GitHub workflow on SEMEVR tag only, [14077386](https://github.com/mrjackwills/oxker/commit/140773865165bf006e74f9d436fc744220f5eae7)
### Refactors
+ replace `unwrap_or(())` with `.ok()`, [8ba37a16](https://github.com/mrjackwills/oxker/commit/8ba37a165bb89277ab957194da6464bdb35be2e6)
+ use `unwrap_or_default()`, [79de92c3](https://github.com/mrjackwills/oxker/commit/79de92c3921702417bb2df1f44939a7b09cb7fa0)
+ Result return, [d9f0bd55](https://github.com/mrjackwills/oxker/commit/d9f0bd5566e27218b8c8eaba6ece237907771c1d)
### Reverts
+ temporary devcontainer buildkit fix removed, [d1497a44](https://github.com/mrjackwills/oxker/commit/d1497a4451f4de54d3cc26c5a3957cd636c29118)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.4'>v0.2.4</a>
### 2023-03-02
### Chores
+ dependencies updated, [aac3ef2b](https://github.com/mrjackwills/oxker/commit/aac3ef2b1def3345d749d813d9b76020d6b5e5ca), [4723be7f](https://github.com/mrjackwills/oxker/commit/4723be7fb2eb101024bb9d5a514e2c6cc51eb6f6), [c69ab4f7](https://github.com/mrjackwills/oxker/commit/c69ab4f7c3b873f25ea46958add37be78d23e9cf), [ba643786](https://github.com/mrjackwills/oxker/commit/ba6437862dae0f422660a602aeabd6217d023fac), [2bb4c338](https://github.com/mrjackwills/oxker/commit/2bb4c338903e09856053894d9646307e31d32f1c)
+ dev container install x86 musl toolchain, [e650034d](https://github.com/mrjackwills/oxker/commit/e650034d50f01a7598876d4f2887df691700e06a)
### Docs
+ typos removed, [23ad9a5f](https://github.com/mrjackwills/oxker/commit/23ad9a5fb3cacf3fb8cb70c65ca9133ed9949e45), [cebb975c](https://github.com/mrjackwills/oxker/commit/cebb975cb82f653407ec801fd8c726ca6ed68289), [fdc67c92](https://github.com/mrjackwills/oxker/commit/fdc67c9249a239bac97a78b20c9378472865209c)
+ comments improved, [ec962295](https://github.com/mrjackwills/oxker/commit/ec962295a8789ff8010604e974969bf618ea7108)
### Features
+ mouse capture is now more specific, should have substantial performance impact, 10x reduction in cpu usage when mouse is moved observed, as well as fixing intermittent mouse events output bug, [0a1b5311](https://github.com/mrjackwills/oxker/commit/0a1b53111627206cc7436589e5b7212e1b72edb8), [93f7c07f](https://github.com/mrjackwills/oxker/commit/93f7c07f708885f8870da5dfb6d57c62f93c9c78), [c74f6c11](https://github.com/mrjackwills/oxker/commit/c74f6c1179b5f62989eb74f395a56b43a8781b03)
+ improve the styling of the help information popup, [28de74b8](https://github.com/mrjackwills/oxker/commit/28de74b866f07c8543e46be3cab929eff28953fd)
+ use checked_sub & checked_div for bounds checks, [72279e26](https://github.com/mrjackwills/oxker/commit/72279e26ae996353c95a75527f704bac1e4bcf4d)
### Fixes
+ correctly set gui error, [340893a8](https://github.com/mrjackwills/oxker/commit/340893a860e99ec4029d12613f2a6de3cb7b47e2)
### Refactors
+ dead code removed, [b8f5792d](https://github.com/mrjackwills/oxker/commit/b8f5792d1865d3a398cd7f23aa9473a55dc6ea44)
+ improve the get_width function, [04c26fe8](https://github.com/mrjackwills/oxker/commit/04c26fe8fc7c79506921b9cff42825b1ee132737)
+ place ui methods into a Ui struct, [3437df59](https://github.com/mrjackwills/oxker/commit/3437df59884f084624031fceb34ea3012a8e2251)
+ get_horizontal/vertical constraints into single method, [e8f5cf9c](https://github.com/mrjackwills/oxker/commit/e8f5cf9c6f8cd5f807a05fb61e31d7cd1426486f)
+ docker update_everything variables, [074cb957](https://github.com/mrjackwills/oxker/commit/074cb957f274675a468f08fecb1c43ff7453217d)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.3'>v0.2.3</a>
### 2023-02-04
### Fixes
+ Container runner `FROM scratch` (missing from v0.2.2 D'oh), this now should actually reduce Docker image size by ~60%, [0bd317b7](https://github.com/mrjackwills/oxker/commit/0bd317b7ce6f9f42a614c488099b5fc7a14d91c7)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.2'>v0.2.2</a>
### 2023-02-04
### Chores
+ devcontainer.json updated, typos-cli installed, temporary(?) buildkit fix, [3c6a8db6](https://github.com/mrjackwills/oxker/commit/3c6a8db6ef74d499b49fabe8912785cac16d9c4b)
+ create_release.sh check for typos, [310a63f4](https://github.com/mrjackwills/oxker/commit/310a63f4cabaa374797a7e4ed0d7fd1f5e79c8fe)
### Docs
+ AUR install instructions, thanks [orhun](https://github.com/orhun), [c5aa346b](https://github.com/mrjackwills/oxker/commit/c5aa346bca139cc5ece1f4127293977924d16fca)
+ typos fixes, thanks [kianmeng](https://github.com/kianmeng), [5052d7ab](https://github.com/mrjackwills/oxker/commit/5052d7ab0a156c43cadbd922c0019b284f24943a)
+ Readme.md styling tweak, [310a63f4](https://github.com/mrjackwills/oxker/commit/310a63f4cabaa374797a7e4ed0d7fd1f5e79c8fe)
+ Contributing guide, [5aaa00d6](https://github.com/mrjackwills/oxker/commit/5aaa00d6a3c58d98cb250b7b14584238df02961c), [a44b15f7](https://github.com/mrjackwills/oxker/commit/a44b15f76088561a0e272d4e7456197c2aaabdb4)
### Features
+ Use a scratch container for the docker image, should reduce image size by around 60%. This checks for the ENV `OXKER_RUNTIME=container`, which is automatically set by the docker image, [17b71b6b](https://github.com/mrjackwills/oxker/commit/17b71b6b41f6a98a0f92277f40a88f4b1b8a1328)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.1'>v0.2.1</a>
### 2023-01-29
### Chores
+ dependencies updated, [c129f474](https://github.com/mrjackwills/oxker/commit/c129f474fe2976454b1868d00e8d7d99b87ec23b), [9788b8af](https://github.com/mrjackwills/oxker/commit/9788b8afd98e59b1d4412a8adc54b34d2c5671fd), [2ab88eb2](https://github.com/mrjackwills/oxker/commit/2ab88eb26e9bbbc4dad4651256d8d9b044ea3272)
### Docs
+ comment typo, [10255791](https://github.com/mrjackwills/oxker/commit/1025579138f11e4987263c7bfe936c4c8542f8b3)
### Fixes
+ deadlock on draw logs when no containers found, [68e444bf](https://github.com/mrjackwills/oxker/commit/68e444bfc393eb46bac2b99eb57697bb9b0451af)
+ github workflow release on main only (with semver tag), [e4ca41df](https://github.com/mrjackwills/oxker/commit/e4ca41dfd8ec3acae202a2d2464b8e18f5c5bdd5), [749ec712](https://github.com/mrjackwills/oxker/commit/749ec712f07cff2c941aed6726c56bdbd5cb8d2c)
### Refactors
+ major refactor of internal data handling, [b4488e4b](https://github.com/mrjackwills/oxker/commit/b4488e4bdb0252f5c5680cee6a46427f22a282ab)
+ needless (double) referencing removed, [a174dafe](https://github.com/mrjackwills/oxker/commit/a174dafe1b05908735680a874dc551a86da24777)
+ app_data methods re-ordered & renamed, [c0bb5355](https://github.com/mrjackwills/oxker/commit/c0bb5355d6a5d352260655110ce3d5ab695acda9)
### Reverts
+ is_running AtomicBool back to SeqCst, [c4d80061](https://github.com/mrjackwills/oxker/commit/c4d80061dab94afd08d4d793dc147f878c965ad6)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.2.0'>v0.2.0</a>
### 2023-01-21
### Chores
+ dependencies updated, [8cd199db](https://github.com/mrjackwills/oxker/commit/8cd199db49186fad6ce432bb277e3a10f0a08d34), [d880b829](https://github.com/mrjackwills/oxker/commit/d880b829c123dbe57deccadef97810e45c083737), [66d57c99](https://github.com/mrjackwills/oxker/commit/66d57c99558ca14d9593d6dbfd5b0e8e5d59055d), [33f93749](https://github.com/mrjackwills/oxker/commit/33f9374908942f4a3b90be227fad94ca353cf351), [007d5d83](https://github.com/mrjackwills/oxker/commit/007d5d83d7f1b93e1e78777a4417b2740db706bd)
+ create_release.sh typos, [9a27d46a](https://github.com/mrjackwills/oxker/commit/9a27d46a044452080144ee1367dc95886b10abf8)
+ dev container post create install cross, [2d253f03](https://github.com/mrjackwills/oxker/commit/2d253f034182741d434e4bac12317f24221d0d4a)
### Features
**all potentially considered breaking changes**
+ store Logs in own struct, use a hashset to track timestamps, hopefully closes [#11](https://github.com/mrjackwills/oxker/issues/11), [657ea2d7](https://github.com/mrjackwills/oxker/commit/657ea2d751a71f05b17547b47c492d5676817336)
+ Spawn docker commands into own thread, can now execute multiple docker commands at the same time, [9ec43e12](https://github.com/mrjackwills/oxker/commit/9ec43e124a62a80f4e78acba85fc3af5980ce260)
+ align memory columns correctly, minimum byte display value now `0.00 kB`, rather than `0 B`, closes [#20](https://github.com/mrjackwills/oxker/issues/20), [bd7dfcd2](https://github.com/mrjackwills/oxker/commit/bd7dfcd2c512a527d66a1388f90006988a487186), [51c58001](https://github.com/mrjackwills/oxker/commit/51c580010a24de2427373795803936d498dc8cee)
### Refactors
+ main.rs tidy up, [97b89349](https://github.com/mrjackwills/oxker/commit/97b89349dc2de275ca514a1e6420255a63d775e8)
+ derive Default for GuiState, [9dcd0509](https://github.com/mrjackwills/oxker/commit/9dcd0509efeb464f58fb53d813bd78de2447949d)
+ param reduction, AtomicBool to Relaxed, [0350293d](https://github.com/mrjackwills/oxker/commit/0350293de3c00c6e5e5d787b7596bb3413d1cda1)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.11'>v0.1.11</a>
### 2023-01-03
### Chores
+ dependencies updated, [9b09146a](https://github.com/mrjackwills/oxker/commit/9b09146aadae5727a5fee4de5fe0c1d70c581c22)
### Features
+ `install.sh` script added, for automated platform selection, download, and installation, [7a42eba6](https://github.com/mrjackwills/oxker/commit/7a42eba6b0968314af40ff87bcc42d288f6860bc), [e0703b76](https://github.com/mrjackwills/oxker/commit/e0703b76a1a28cfe266f130a7f7dec92f1b5ad58)
### Fixes
+ If a sort order is set, sort containers on every `update_stats()` execution, [cfdea775](https://github.com/mrjackwills/oxker/commit/cfdea77594e48c8c20a4d6e6c7ea31c9181361a1)
### Refactors
+ input sort executed in app_data struct `sort_by_header()`, [3cdc5fae](https://github.com/mrjackwills/oxker/commit/3cdc5fae02097628799209f371ae9292e513e76c)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.10'>v0.1.10</a>
### 2022-12-25
### Chores
+ dependencies updated, [1525b315](https://github.com/mrjackwills/oxker/commit/1525b3150293015c0fb2f2161da463b21ac2694c), [8d539ab1](https://github.com/mrjackwills/oxker/commit/8d539ab14809136d743c49d60779687fc8eeef6d), [1774217a](https://github.com/mrjackwills/oxker/commit/1774217a8a657d261397d213e5ecee667cf3b6b1)
+ Rust 1.66 linting, [bf9dcac7](https://github.com/mrjackwills/oxker/commit/bf9dcac7045c0d2314df147ec2744a3ad886564b)
### Features
+ Caching on github action, [a91c9aa4](https://github.com/mrjackwills/oxker/commit/a91c9aa45ffd5c998cd1b83d8e90d0912893c31f)
### Fixes
+ comment typo, [7899b773](https://github.com/mrjackwills/oxker/commit/7899b773569fed86343a035d3023bf34297fdabb)
### Refactors
+ remove_ansi() to single liner, [57c3a6c1](https://github.com/mrjackwills/oxker/commit/57c3a6c186b916faba24bf7b5cdbbda31d636a7e)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.9'>v0.1.9</a>
### 2022-12-05
### Fixes
+ disallow commands to be sent to a dockerised oxker container, closes [#19](https://github.com/mrjackwills/oxker/issues/19), [160b8021](https://github.com/mrjackwills/oxker/commit/160b8021b1de898064756b53c127d49b8096ce4d)
+ if no container created time, use 0, instead of system_time(), [1adb61ce](https://github.com/mrjackwills/oxker/commit/1adb61ce3b029d4fcf51961958d483b2fae8825a)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.8'>v0.1.8</a>
### 2022-12-05
### Chores
+ dependencies updated, [e3aa4420](https://github.com/mrjackwills/oxker/commit/e3aa4420cb510df0381e311d37e768937070387a)
+ docker-compose.yml alpine bump, [911c6596](https://github.com/mrjackwills/oxker/commit/911c6596684db4ccbe7a55aadd6f595a95f89bb0)
+ github workflow use dtolnay/rust-toolchain, [57c18878](https://github.com/mrjackwills/oxker/commit/57c18878690477a05d7330112a65d1d58a07901e)
### Features
+ Clicking a header now toggles between Ascending -> Descending -> Default. Use the containers created_time as the default order - maybe add created column in future version, closes [#18](https://github.com/mrjackwills/oxker/issues/18), [cf14ba49](https://github.com/mrjackwills/oxker/commit/cf14ba498987db587c0f5bef8a67cf4113ffcb1e), [d1de2914](https://github.com/mrjackwills/oxker/commit/d1de291473d8a1028f1936429832d3820d75df54)
+ `-s` flag for showing the oxker container when executing the docker image, [c93870e5](https://github.com/mrjackwills/oxker/commit/c93870e5fbbc7df35c69d32e4460d2104e521e33)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.7'>v0.1.7</a>
### 2022-11-13
### Chores
+ update dependencies, closes [#17](https://github.com/mrjackwills/oxker/issues/17), [8a5d0ef8](https://github.com/mrjackwills/oxker/commit/8a5d0ef8376e3739dda5b0ed4c3e75e565deed45), [eadfc3d6](https://github.com/mrjackwills/oxker/commit/eadfc3d6c6896ecc8cff88c6a9e9c8b3e477c0cd)
+ aggressive linting with Rust 1.65.0, [8f3a1513](https://github.com/mrjackwills/oxker/commit/8f3a15137155dc374e6b2822c9155c07d05d5e28)
### Docs
+ README.md improved Download & Install section, and now available on [NixPkg](https://search.nixos.org/packages?channel=unstable&show=oxker&from=0&size=50&sort=relevance&type=packages&query=oxker), thanks [siph](https://github.com/siph), [67a9e183](https://github.com/mrjackwills/oxker/commit/67a9e183ca04199da758255075ff7e73061eb850)
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.6'>v0.1.6</a>
### 2022-10-16
### Chores
+ Cargo update, [c3e72ae7](https://github.com/mrjackwills/oxker/commit/c3e72ae7369a25d903f39e55a4349cb005671dd4),
+ create_release.sh v0.1.0, [3c8d59c6](https://github.com/mrjackwills/oxker/commit/3c8d59c666bd4cda9ca54989b2f1b48bba17bc57),
+ uuid updated to version 1.2, [438ad770](https://github.com/mrjackwills/oxker/commit/438ad770f4a5ecb5f4bbc308066ad9e808f66514),
### Fixes
+ loading icon shifting error fix, also make icon white, closes [#15](https://github.com/mrjackwills/oxker/issues/15), [59797685](https://github.com/mrjackwills/oxker/commit/59797685dffa29752a48c98e6cf465884d6d9df6),
### Features
+ Show container name in log panel title, closes [#16](https://github.com/mrjackwills/oxker/issues/16), [9cb0c414](https://github.com/mrjackwills/oxker/commit/9cb0c414afc284947fc2b8494504387e4e7edd87),
+ use gui_state HashSet to keep track of application gui state, [9e9d5155](https://github.com/mrjackwills/oxker/commit/9e9d51559a13944622abf4fcbd3bd63766d11467),
+ terminal.clear() after run_app finished, [67c49575](https://github.com/mrjackwills/oxker/commit/67c49575682cb271fac0998ff377a6504cd0bc86),
### Refactors
+ CpuStats & MemStats use tuple struct, [a060d032](https://github.com/mrjackwills/oxker/commit/a060d032586a0707ac91cb13d922aae0850449c5),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.5'>v0.1.5</a>
### 2022-10-07
### Chores
+ Update clap to v4, [15597dbe](https://github.com/mrjackwills/oxker/commit/15597dbe6942ec053541398ce0e9dedc10a4d3ea),
### Docs
+ readme.md updated, [a05bf561](https://github.com/mrjackwills/oxker/commit/a05bf561cc6d96237f683ab0b3c782d6841974d9),
### Features
+ use newtype construct for container id, [41cbb84f](https://github.com/mrjackwills/oxker/commit/41cbb84f2896f8be2c37eba87e390d998aff7382),
### Refactors
+ Impl Copy where able to, [e76878f4](https://github.com/mrjackwills/oxker/commit/e76878f424d72b943713ef84e95e25fada77d79e),
+ replace async fn with just fn, [17dc604b](https://github.com/mrjackwills/oxker/commit/17dc604befac75cb9dc0311a0e43f9927fe0ca30),
+ remove pointless clone()'s & variable declarations, [6731002e](https://github.com/mrjackwills/oxker/commit/6731002ee42c9460042c2c38aff5101b1bcebbe6),
+ replace String::from("") with String::new(), [62fb2247](https://github.com/mrjackwills/oxker/commit/62fb22478697cc9a7ab9fb562a724965b437233a),
+ replace map_or_else with map_or, [3e26f292](https://github.com/mrjackwills/oxker/commit/3e26f292c7dc5e13af4580952767ebe821aa5183), [5660b34d](https://github.com/mrjackwills/oxker/commit/5660b34d5149dce27706ff6daa90b854e6f84e14),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.4'>v0.1.4</a>
### 2022-09-07
### Chores
+ dependencies updated, [a3168daa](https://github.com/mrjackwills/oxker/commit/a3168daa3f769a6747dfbe61103073a7e80a1485),[78e59160](https://github.com/mrjackwills/oxker/commit/78e59160bb6a978ee80e3a99eb72f051fb64e737),
### Features
+ containerize self, github action to build and push to [Docker Hub](https://hub.docker.com/r/mrjackwills/oxker), [07f97202](https://github.com/mrjackwills/oxker/commit/07f972022a69f22bac57925e6ad84234381f7890),
+ gui_state is_loading use a HashSet to enable multiple things be loading at the same time, [66583e1b](https://github.com/mrjackwills/oxker/commit/66583e1b037b7e2f3e47948d70d8a4c6f6a2f2d5),
+ github action publish to crates.io, [90b2e3f6](https://github.com/mrjackwills/oxker/commit/90b2e3f6db0d5f63840cd80888a30da6ecc22f20),
+ derive Eq where appropriate, [d7c2601f](https://github.com/mrjackwills/oxker/commit/d7c2601f959bc12a64cd25cef59c837e1e8c2b2a),
+ ignore containers 'oxker' containers, [1be9f52a](https://github.com/mrjackwills/oxker/commit/1be9f52ad4a68f93142784e9df630c59cdec0a79),
+ update container info if container is either running OR restarting, [5f12362d](https://github.com/mrjackwills/oxker/commit/5f12362db7cb61ca68f75b99ecfc9725380d87d2),
### Fixes
+ devcontainer updated, [3bde4f56](https://github.com/mrjackwills/oxker/commit/3bde4f5629539cab3dbb57556663ab81685f9d7a),
+ Use Binate enum to enable two cycles of cpu/mem update to be executed (for each container) at the same time, refactor hashmap spawn insertions, [7ec58e79](https://github.com/mrjackwills/oxker/commit/7ec58e79a1316ad1f7e50a2781dea0fe8422c588),
### Refactors
+ improved way to remove leading '/' of container name, [832e9782](https://github.com/mrjackwills/oxker/commit/832e9782d7765872cbb84df6b3703fc08cb353c9),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.3'>v0.1.3</a>
### 2022-08-04
### Chores
+ dependencies updated, [d9801cdf](https://github.com/mrjackwills/oxker/commit/d9801cdf372521fe5624a8d68fac83ed39ef81f4),
+ linting: nursery, pedantic, unused_unwraps, [1bd61d4c](https://github.com/mrjackwills/oxker/commit/1bd61d4ce8b369d6d078201add3eea0f59fe0dea), [1263662b](https://github.com/mrjackwills/oxker/commit/1263662bd9412afacddbc10721bf216ae3a843f1), [ca3315a6](https://github.com/mrjackwills/oxker/commit/ca3315a69f593ad705eb637f227f195edd7781b2),
### Features
+ build all production targets on release, [44f8140e](https://github.com/mrjackwills/oxker/commit/44f8140eaec330abe5a94f3ddae9e8b223688aa8),
### Fixes
+ toml keywords, [dd2d82d1](https://github.com/mrjackwills/oxker/commit/dd2d82d114537e09dbeb12f360157f0e68e7846e),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.2'>v0.1.2</a>
### 2022-07-23
### Fixes
+ remove reqwest dependency, [10ff8bab](https://github.com/mrjackwills/oxker/commit/10ff8bab5f01f097fd6cdec60b2be947f238197b),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.1'>v0.1.1</a>
### 2022-07-23
### Chores
+ update Cargo.toml, in preparation for crates.io publishing, [fdc6898e](https://github.com/mrjackwills/oxker/commit/fdc6898e20c41415f03e310d7b84af4b6c39ab62),
### Docs
+ added cargo install instructions, [c774b10d](https://github.com/mrjackwills/oxker/commit/c774b10d557b10885b9d3a0b3612330a8ecb1cd5),
### Fixes
+ use SpawnId for docker hashmap JoinHandle mapping, [1ae95d58](https://github.com/mrjackwills/oxker/commit/1ae95d58c3302a95d5a0a2f0b61b126c72b6e166),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.1.0'>v0.1.0</a>
### 2022-07-23
### Chores
+ dependencies updated, [cf7e02dd](https://github.com/mrjackwills/oxker/commit/cf7e02dde94f69832a2e485b99785afc66a5bc15),
### Features
+ Enable sorting of containers by each, and every, heading. Either via keyboard or mouse, closes [#3](https://github.com/mrjackwills/oxker/issues/3), [a6c296f2](https://github.com/mrjackwills/oxker/commit/a6c296f2cde56cf241bcd696cab8bd477270e5f4),
+ Spawn & track docker information update requests, multiple identical requests cannot be executed, [740c059b](https://github.com/mrjackwills/oxker/commit/740c059b276f35acd1cb03f1030134646bf8a07d),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.6'>v0.0.6</a>
### 2022-07-06
### Docs
+ readme update, [f29e29ad](https://github.com/mrjackwills/oxker/commit/f29e29ad151ddf424ba630e6d33edf19acfd7636),
+ comments improved, [1674db8a](https://github.com/mrjackwills/oxker/commit/1674db8a20aafa447732deb2e44ac8b97cf0471b),
+ readme logo size, [a733efa6](https://github.com/mrjackwills/oxker/commit/a733efa65865e04d9ec86c7ca8785dfbae635695),
### Fixes
+ Remove unwraps(), [61db81ec](https://github.com/mrjackwills/oxker/commit/61db81ecfe5684ddb8a360715f43357a042162c0),
+ Help menu alt+tab > shift+tab typo, thanks [siph](https://github.com/siph), [04466803](https://github.com/mrjackwills/oxker/commit/04466803481b75feb7d7f275248279fdb8729862),
### Refactors
+ tokio spawns, [1fd230f2](https://github.com/mrjackwills/oxker/commit/1fd230f2f3cf4e376058359515e76f4fa6e425c2),
+ max_line_width(), [a5d7dabb](https://github.com/mrjackwills/oxker/commit/a5d7dabbd68dc15a081df33352ce3b55d9a9891c),
+ create_release dead code removed, [297979c1](https://github.com/mrjackwills/oxker/commit/297979c197c2defd409053d8da724f922b0bba1b),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.5'>v0.0.5</a>
### 2022-05-30
### Docs
+ Readme one-liner to download & install latest version, [11d5ba36](https://github.com/mrjackwills/oxker/commit/11d5ba361ee4c11d080f1c3c14d8bb677cbfd1fc),
+ Example docker-compose.yml bump alpine version to 3.16, [98c83f2f](https://github.com/mrjackwills/oxker/commit/98c83f2f68f59e78f0c78270c59886630d98913c),
### Fixes
+ use Some() checks to make sure that container item indexes are still valid, else can create out-of-bounds errors, closes [#8](https://github.com/mrjackwills/oxker/issues/8), [4cf02e3f](https://github.com/mrjackwills/oxker/commit/4cf02e3f04426ef44ec5a7421687f2104ac5102f),
+ Remove + replace as many unwrap()'s as possible, [d8e22d74](https://github.com/mrjackwills/oxker/commit/d8e22d7444965f1874d7367259310440a889432b),
+ Help panel typo, [e497f3f2](https://github.com/mrjackwills/oxker/commit/e497f3f2d9e1dca99469860c2e728c99e29353ad),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.4'>v0.0.4</a>
### 2022-05-08
### Fixes
+ Help menu logo corrected, [2f545202](https://github.com/mrjackwills/oxker/commit/2f5452027e86f714729b804d4bf65306e755df7f),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.3'>v0.0.3</a>
### 2022-05-08
### Docs
+ slight readme tweaks, [eb9184a1](https://github.com/mrjackwills/oxker/commit/eb9184a1aee64be1c20fabd482bfcbe676bed049),
### Features
+ vim movement keys, 'j' & 'k', to move through menus, thanks [siph](https://github.com/siph), [77eb33c0](https://github.com/mrjackwills/oxker/commit/77eb33c008e36965d84d1eafbbc3733af19fd262),
### Fixes
+ create_release.sh correctly link to closed issues, [5820d0a9](https://github.com/mrjackwills/oxker/commit/5820d0a9b68ead71d031377c5d22138675d7dfa8),
### Refactors
+ generate_block reduce params, insert into area hashmap from inside generate_block function, [32705a60](https://github.com/mrjackwills/oxker/commit/32705a60c4f865eb829cc460b2ac82db79107c1a),
+ dead code removed, [d20e1bcd](https://github.com/mrjackwills/oxker/commit/d20e1bcd47965859a04f8e080509a5afb2de36d9),
+ create_release.sh improved flow & comments, [4283a285](https://github.com/mrjackwills/oxker/commit/4283a285e2e60907e432294e3b97a759ec06a23d),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.2'>v0.0.2</a>
### 2022-04-29
### Features
+ allow toggling of mouse capture, to select & copy text with mouse, closes [#2](https://github.com/mrjackwills/oxker/issues/2), [aec184ea](https://github.com/mrjackwills/oxker/commit/aec184ea22b289e91942a4c3e6a415685884bc47),
+ show id column, [b10f9274](https://github.com/mrjackwills/oxker/commit/b10f927481c9e38a48c1d4b94e744ec48e8b6ba6),
+ draw_popup, using enum to draw in one of 9 areas, closes [#6](https://github.com/mrjackwills/oxker/issues/6), [1017850a](https://github.com/mrjackwills/oxker/commit/1017850a6cc91328abc1127bdb117495f5e909d8),
+ use a message rx/sx for all docker commands, remove update loop, wait for update message from gui instead, [9b70fdfa](https://github.com/mrjackwills/oxker/commit/9b70fdfad7b38361ebee301bdc2545d3f0dfcf9e),
### Fixes
+ readme.md typo, [589501f9](https://github.com/mrjackwills/oxker/commit/589501f9a4a0bfabdb0654e68cc0c752c529d97a),
+ column heading mem > memory, [5e8e6b59](https://github.com/mrjackwills/oxker/commit/5e8e6b590b06f01a542fdd10bae8f14d303ab08a),
+ cargo fmt added to create_release.sh, [bb29c0eb](https://github.com/mrjackwills/oxker/commit/bb29c0ebfafd6a9a036eb317a240954d1405966e),
# <a href='https://github.com/mrjackwills/oxker/releases/tag/v0.0.1'>v0.0.1</a>
### 2022-04-25
+ init commit
+105
View File
@@ -0,0 +1,105 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Oxker is a Go rewrite of the original Rust-based TUI for viewing and controlling Docker containers. It uses the Bubbletea framework for the terminal UI and the Docker SDK for container management.
**Note:** The git history still contains deleted Rust source files (src/, Cargo.toml, etc.). Original Rust source is preserved in `source/` for reference. The active codebase is entirely Go.
## Build & Run
```bash
# Build
go build -o oxker ./cmd/oxker
# Run
./oxker
# Run with debug mode (no TUI)
./oxker -g
# Vet
go vet ./...
# Test
go test ./...
# Run a single test
go test ./cmd/oxker -run TestAppStart
# Tidy modules
go mod tidy
# All-in-one build script (build + vet + tidy)
./build.sh
```
## Architecture
The app follows the Elm architecture (Model-View-Update) via Bubbletea:
- **`cmd/oxker/`** - Entry point and CLI argument parsing
- `main.go` - Creates `tea.Program` with AltScreen + mouse support, passes config to `app.New(cfg)`
- `cli.go` - Flag definitions, config loading, CLI-to-config mapping
- **`internal/app/app.go`** - Central application model (~1850 lines, self-contained). Contains:
- `App` struct implementing `tea.Model` (Init/Update/View)
- `Container` data type with 60-sample rolling stats buffers (CPU, mem, RX, TX)
- Container state machine: RunningHealthy, RunningUnhealthy, Paused, Exited, Dead, Restarting, Removing, Created, Unknown
- Catppuccin Mocha color palette
- Sorting (9 columns, asc/desc toggle), filtering (4 modes: Name/Image/Status/All), log search with match navigation
- Sparkline charts for CPU, Memory, and Network bandwidth
- Mouse support (wheel scroll, click to select containers, click headers to sort)
- Docker exec via `tea.ExecProcess` (suspends TUI, runs `docker exec -it <id> sh`)
- Inspect panel (JSON), delete confirmation popup, error/info overlays
- Log panel: resize, toggle, horizontal scroll, save to file
- SI units (1000-based) for byte formatting
- CPU calculation: `(cpu_delta / system_delta) * online_cpus * 100.0`
- **`internal/docker/client.go`** - Thin wrapper around `github.com/docker/docker/client`. Key methods:
- `ContainerStatsOneShot` - Single stats snapshot with proper JSON decoding
- `Logs` - Container log streaming
- `ContainerExec` - Docker exec API (used by client, app uses CLI `docker exec` via `tea.ExecProcess`)
- Standard CRUD: Start, Stop, Restart, Delete, Pause, Unpause, Inspect
- **`internal/config/config.go`** - Configuration with TOML and JSONC support. Includes:
- `AppColors` - Full color theming for every UI element
- `Keymap` - Customizable key bindings with primary/secondary support
- Config file resolution: absolute path, relative to CWD, then `~/.config/oxker/`
- **`internal/input/`**, **`internal/ui/`**, **`internal/utils/`** - Legacy modules from earlier architecture, currently unused by app.go
## Key Dependencies
- `github.com/charmbracelet/bubbletea` v1.3.10 - TUI framework (Elm architecture)
- `github.com/charmbracelet/lipgloss` - Terminal styling
- `github.com/docker/docker` - Docker SDK
- `github.com/BurntSushi/toml` - TOML config parsing
## Data Flow
1. `main.go` parses CLI args -> loads config -> creates `App` model with config -> starts `tea.Program`
2. `App.Init()` fires `loadContainers()` and `tickCmd()` concurrently
3. `tickCmd()` fires every `DockerIntervalMs` (default 1000ms), triggers container list reload + stats updates
4. Stats collected via `ContainerStatsOneShot` (one-shot, not streaming) for each running container
5. Key/mouse events route through `App.Update()` -> mode-specific handler (normal, filter, search, inspect, delete confirm)
6. Container actions dispatch via `doAction()` which calls Docker SDK through `CmdMgr`
## CLI Flags
Key flags: `-d` (update interval ms), `-r` (raw logs), `-c` (color logs), `-t` (timestamps), `-s` (show self), `-g` (debug/no-TUI), `-config-file`, `-host`, `-timezone`, `-use-cli`, `-no-stderr`, `-save-dir`
## Mouse API (bubbletea v1.3.10)
`tea.MouseMsg` is a struct (not interface). Use `msg.Action` (MouseActionPress/Release/Motion) and `msg.Button` (MouseButtonLeft/WheelUp/WheelDown etc.) directly. No subtypes like MouseWheelMsg or MouseClickMsg.
## JCodeMunch (jmunch) Usage
- Start with `get_repo_outline` to quickly understand the repository structure.
- Use `get_file_outline` before reading source to understand the API surface first.
- Narrow searches using `kind`, `language`, and `file_pattern`.
- Batch-retrieve related symbols with `get_symbols` instead of repeated `get_symbol` calls.
- Use `search_text` when symbol search does not locate the needed content.
- Use `verify: true` on `get_symbol` to detect source drift since indexing.
-14
View File
@@ -1,14 +0,0 @@
# Contributing to oxker
oxker encourages any, and all, suggestions, bug reports, pull requests, and/or feedback.
## Submitting Issues
Please use the oxker [issue templates](https://github.com/mrjackwills/oxker/issues/new/choose) for any Bug Report, Feature Suggestions,
Refactor Idea, or Security Vulnerabilities.
Don't hesitate to submit any issues or pull requests, regardless of size.
## Conduct
oxker follows the [Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct)
Generated
-2989
View File
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
[package]
name = "oxker"
version = "0.13.0"
edition = "2024"
authors = ["Jack Wills <email@mrjackwills.com>"]
description = "A simple tui to view & control docker containers"
repository = "https://github.com/mrjackwills/oxker"
homepage = "https://github.com/mrjackwills/oxker"
license = "MIT"
rust-version = "1.90.0"
readme = "README.md"
keywords = ["docker", "tui", "tokio", "terminal", "podman"]
categories = ["command-line-utilities"]
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
expect_used = "warn"
todo = "warn"
unused_async = "warn"
unwrap_used = "warn"
module_name_repetitions = "allow"
doc_markdown = "allow"
similar_names = "allow"
[dependencies]
anyhow = "1.0"
bollard = "0.20"
cansi = "2.2"
clap = { version = "4.5", features = ["color", "derive", "unicode"] }
crossterm = "0.29"
directories = "6.0"
futures-util = "0.3"
jiff = { version = "0.2", features = ["tzdb-bundle-always"] }
parking_lot = { version = "0.12" }
ratatui = "0.30"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0"}
serde_jsonc = "1.0"
tokio = { version = "1.49", features = ["full"] }
tokio-util = "0.7"
toml = { version = "1.0", default-features = false, features = ["parse", "serde"] }
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1.21", features = ["fast-rng", "v4"] }
[dev-dependencies]
insta = "1.46"
serde_json = { version = "1.0", features = ["preserve_order"]}
[profile.release]
lto = true
codegen-units = 1
panic = 'abort'
strip = true
debug = false
+28 -219
View File
@@ -1,238 +1,47 @@
<p align='center'>
<img src='./.github/logo.svg' width='100px' />
<br>
<h1 align='center'>oxker</h1>
<div align='center'>A simple tui to view & control docker containers</div>
</p>
# Oxker - Go Version
<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/tui-rs-revival/ratatui' target='_blank' rel='noopener noreferrer'>ratatui</a> & <a href='https://github.com/fussybeaver/bollard' target='_blank' rel='noopener noreferrer'>Bollard</a>
</p>
A simple TUI to view & control Docker containers, built with Bubbletea.
<p align='center'>
<!-- ffmpeg -i input.mp4 -vf "scale='min(1000,iw)':-1:flags=lanczos,fps=2" -vcodec libwebp -lossless 0 -compression_level 4 -q:v 85 -loop 0 demo_01.webp -->
<a href='https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/demo_01.webp' target='_blank' rel='noopener noreferrer'>
<img src='./.github/demo_01.webp' width='100%' alt='An animated demo of oxker in operation'/>
</a>
<sub>
<!-- TODO update this -->
<a href="https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/screenshot_01.png" target='_blank' rel='noopener noreferrer'>
link to alternative screenshot
</a>
</sub>
</p>
## Features
- [Download & install](#download--install)
- [Run](#run)
- [Build step](#build-step)
- [Tests](#tests)
- View Docker containers in a table format
- Filter containers
- Start/Stop containers
- Container details
## Download & install
## Requirements
### Cargo
Published on <a href='https://www.crates.io/crates/oxker' target='_blank' rel='noopener noreferrer'>crates.io</a>, so if you have cargo installed, simply run
- Go 1.23+
- Docker installed and running
## Installation
```shell
cargo install oxker
```bash
go install github.com/oxker/oxker@latest
```
### Docker
## Usage
Published on <a href='https://ghcr.io/mrjackwills/oxker' target='_blank' rel='noopener noreferrer'>ghcr.io</a> and <a href='https://hub.docker.com/r/mrjackwills/oxker' target='_blank' rel='noopener noreferrer'>Docker Hub</a>,
with images built for `linux/amd64`, `linux/arm64`, and `linux/arm/v6`
**via ghcr.io**
```shell
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always ghcr.io/mrjackwills/oxker
```
**via Docker Hub**
```shell
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always mrjackwills/oxker
```
### Nix
Using nix flakes, oxker can be ran directly with
```shell
nix run nixpkgs#oxker
```
Without flakes, you can build a shell that contains oxker using
```shell
nix-shell -p oxker
```
### AUR
oxker can be installed from the [AUR](https://aur.archlinux.org/packages/oxker) with using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers):
```shell
paru -S oxker
```
### Homebrew
oxker can be installed on macOS using [Homebrew](https://formulae.brew.sh/formula/oxker):
```shell
brew install oxker
```
### Pre-Built
See the <a href='https://github.com/mrjackwills/oxker/releases/latest' target='_blank' rel='noopener noreferrer'>pre-built binaries</a>
or, download & install (x86_64 one liner)
```shell
wget https://www.github.com/mrjackwills/oxker/releases/latest/download/oxker_linux_x86_64.tar.gz &&
tar xzvf oxker_linux_x86_64.tar.gz oxker &&
install -Dm 755 oxker -t "${HOME}/.local/bin" &&
rm oxker_linux_x86_64.tar.gz oxker
```
or, for automatic platform selection, download, and installation (to `$HOME/.local/bin`)
*One should always verify <a href='https://github.com/mrjackwills/oxker/blob/main/install.sh' target='_blank' rel='noopener noreferrer'>script content</a> before running in a shell*
```shell
curl https://raw.githubusercontent.com/mrjackwills/oxker/main/install.sh | bash
```
## Run
```shell
```bash
oxker
```
In application controls, these, amongst many other settings, can be customized with the [config file](#Config-File)
| button| result|
|--|--|
| ```( tab )``` or ```( shift+tab )``` | Change panel, clicking on a panel also changes the selected panel.|
| ```( ↑ ↓ )``` or ```( j k )``` or ```( Home End )```| Scroll line in selected panel - mouse wheel will also scroll.|
| ```( ← → )``` | Scroll horizontally across text.|
| ```( ctrl )``` | Increase scroll speed, used in conjunction with scroll keys.|
| ```( enter )```| Run selected docker command.|
| ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. |
| ```( 0 )``` | Stop sorting.|
| ```( F1 )``` or ```( / )``` | Enter filter mode. |
| ```( # )``` | Enter log search mode. |
| ```( - ) ``` or ```(=)``` | Reduce or increase the height of the logs panel.|
| ```( \ )``` | Toggle the visibility of the logs panel.|
| ```( e )``` | Exec into the selected container - not available on Windows.|
| ```( i )``` | Enter container inspect mode. |
| ```( f )``` | Force clear the screen & redraw the gui.|
| ```( h )``` | Toggle help menu.|
| ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.|
| ```( q )``` | Quit.|
| ```( s )``` | Save logs to `$HOME/[container_name]_[timestamp].log`, or the directory set by `--save-dir`.|
| ```( esc )``` | Close dialog.|
Available command line arguments
## Controls
| argument|result|
|--|--|
|```-d [number > 0]```| Set the minimum update interval for docker information in milliseconds. Defaults to 1000 (1 second).|
|```-r```| Show raw logs. By default, removes ANSI formatting (conflicts with `-c`).|
|```-c```| Attempt to color the logs (conflicts with `-r`).|
|```-t```| Remove timestamps from each log entry.|
|```-s```| If running via Docker, will display the oxker container.|
|```-g```| No TUI, essentially a debugging mode with limited functionality, for now.|
|```--config-file [string]```| Location of a `config.toml`/`config.json`/`config.jsonc`. By default will check the users local config directory.|
|```--host [string]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.|
|```--no-stderr```| Do not include stderr output in logs.|
|```--save-dir [string]```| Save exported logs into a custom directory. Defaults to `$HOME`.|
|```--timezone [string]```| Display the Docker logs timestamps in a given [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Defaults to `Etc/UTC`.|
|```--use-cli```| Use the Docker application when exec-ing into a container, instead of the Docker API.|
| Key | Action |
|-----|--------|
| q | Quit |
| r | Refresh containers |
| j/k | Navigate up/down |
| enter | View container details |
| ctrl+c | Quit |
### Config File
## Building
A config file enables the user to persist settings, create a custom keymap, set the color scheme used by the application, and more.
<br>
<br>
Examples of the config file, alsong with explanations of each value, can be found in the [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config) directory. `oxker` supports `.toml`,`.json`, and `.jsonc` file formats.
<br>
<br>
If no config file is found, a `config.toml` file will be created in an `oxker` directory in the user's local config directory, as found by the [directories crate](https://docs.rs/directories/6.0.0/directories/struct.BaseDirs.html#method.config_local_dir).
<br>
<br>
Command line arguments will take priority over values from the config file.
<br>
<br>
If running an `oxker` container, the default config location will be `/` rather than the automatically detected platform-specific local config directory, and can be mounted as follows;
```shell
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro -v /some_location/config.toml:/config.toml:ro ghcr.io/mrjackwills/oxker
```bash
go build -o oxker
```
## Build step
## License
### x86_64
```shell
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)
```shell
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, suggest running with a -d argument of 5000
```shell
cross build --target arm-unknown-linux-musleabihf --release
```
If no memory information available, try appending either ```/boot/cmdline.txt``` or ```/boot/firmware/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>
### Untested on other platforms
## Tests
~~As of yet untested, needs work~~
The work has been done, so far the tests don't effect any running containers, but this may change in the future.
```shell
cargo test
```
Run some example docker images
using docker/docker-compose.yml;
```shell
docker compose -f ./docker/docker-compose.yml up -d
```
or individually
```shell
docker run --name redis -d redis:alpine3.21
```
```shell
docker run --name postgres -e POSTGRES_PASSWORD=never_use_this_password_in_production -d postgres:alpine3.21
```
```shell
docker run -d --hostname my-rabbit --name rabbitmq rabbitmq:3
```
MIT
+454
View File
@@ -0,0 +1,454 @@
# Oxker Go Rewrite — Review Findings
Systematischer Vergleich aller Original-Rust-Dateien mit der Go-Reimplementierung.
6 parallele Review-Agenten, ~130 Findings gesamt.
---
## CRITICAL (App funktioniert falsch)
### C1. CPU immer 0 — ContainerStatsOneShot liefert leere precpu_stats
- **Source:** Docker Integration Review #11
- **Rust:** `docker_data/mod.rs:136-143``stream: false, one_shot: false` + `.take(1)`
- **Go:** `docker/client.go:70``ContainerStatsOneShot` setzt `one-shot=true`
- **Problem:** Mit `one-shot=true` liefert Docker leere `precpu_stats`, CPU-Berechnung ergibt 0
- **Fix:** `ContainerStats` mit `stream=false` statt `ContainerStatsOneShot` verwenden
- **Status:** [x] FIXED
### C2. uint64 Underflow bei CPU-Berechnung
- **Source:** Docker Integration Review #6/#7
- **Rust:** `docker_data/mod.rs:86-91``saturating_sub` verhindert Underflow
- **Go:** `app.go:1120-1121` — plain uint64 Subtraktion, kann wrappen
- **Fix:** Guard: `if TotalUsage < PreTotalUsage { return 0 }`
- **Status:** [x] FIXED
### C3. --host Flag wird ignoriert
- **Source:** Docker Integration Review #1
- **Rust:** `main.rs:47-57` — Config Host > DOCKER_HOST env > default socket
- **Go:** `docker/client.go:17-22` — nur `FromEnv`, ignoriert `Config.Host`
- **Fix:** `docker.New()` soll optionalen Host akzeptieren, `client.WithHost(host)` verwenden
- **Status:** [x] FIXED
### C4. Logs ShowStderr Config ignoriert
- **Source:** Docker Integration Review #8
- **Rust:** `docker_data/mod.rs:256-258``config.show_std_err`
- **Go:** `app.go:1089-1091` — hardcoded `ShowStderr: true`
- **Fix:** `ShowStderr: a.Config.ShowStdErr`
- **Status:** [x] FIXED
### C5. Bandwidth-Chart zeigt kumulative Bytes statt Rate
- **Source:** Data Model Review #8
- **Rust:** `container_state.rs:627-672``NetworkBandwidth.to_vec_f64` berechnet Deltas
- **Go:** `app.go:1493-1508``fmtRate(c.RxBytes)` auf kumulative Rohwerte
- **Fix:** Bandwidth als Differenz zwischen aufeinanderfolgenden Einträgen berechnen
- **Status:** [x] FIXED
### C6. RunningUnhealthy — fehlt pause/restart in Commands
- **Source:** Data Model Review #2, Main UI Review #6
- **Rust:** `container_state.rs:451``Running(_) => [Pause, Restart, Stop, Delete]`
- **Go:** `app.go:1749-1750``RunningUnhealthy` nur `["stop", "delete"]`
- **Fix:** Gleiche Commands wie RunningHealthy
- **Status:** [x] FIXED
### C7. Enter führt immer ersten Command aus — kein Command-Selection
- **Source:** Input Review #6, Data Model Review #4, Main UI Review #8
- **Rust:** `input_handler/mod.rs:267` — prüft `panel == Commands`, nutzt `selected_docker_controls()`
- **Go:** `app.go:922``cmds[0]` immer, unabhängig von Panel
- **Fix:** `CmdSelectedIdx` Feld, nur bei `ActivePanel == PanelCommands` ausführen
- **Status:** [x] FIXED
### C8. Ctrl+C quit nicht in Filter/Search-Modus
- **Source:** Input Review #9
- **Rust:** `input_handler/mod.rs:762-763` — Ctrl+C immer quit
- **Go:** `app.go:360-367` — Filter/Search early return, Ctrl+C wird nie erreicht
- **Fix:** Ctrl+C Check vor Mode-Dispatch
- **Status:** [x] FIXED
### C9. Mouse-Events nicht blockiert in Overlay-Modi
- **Source:** Input Review #10
- **Rust:** `input_handler/mod.rs:74-84` — blockiert Mouse in Error/Help/Filter/Search/DeleteConfirm
- **Go:** `app.go:638` — keine Mode-Checks, Mouse immer aktiv
- **Fix:** Mode-Checks am Anfang von `handleMouse`
- **Status:** [x] FIXED
### C10. Info-Popup Auto-Dismiss fehlt (4 Sekunden)
- **Source:** Popups Review #9
- **Rust:** `info.rs:52-54``instant.elapsed() > 4000ms`
- **Go:** `app.go:1003-1006` — Kommentar sagt "handled by tick", aber kein Timer
- **Fix:** `tea.Tick(4*time.Second)``InfoDismissMsg`
- **Status:** [x] FIXED
---
## HIGH (UI sieht falsch aus)
### H1. CPU-Format ohne Zero-Padding
- **Source:** Main UI Review #2
- **Rust:** `container_state.rs:521``{:05.2}%``00.00%`, `05.34%`
- **Go:** `app.go:1325``%.2f%%``0.00%`, `5.34%`
- **Fix:** `fmt.Sprintf("%05.2f%%", c.CPUPercent)`
- **Status:** [x] FIXED
### H2. Container-Spalten ohne Farben (Name/ID/Image/RX/TX)
- **Source:** Main UI Review #10/#11
- **Rust:** `containers.rs:26-33` — Name/ID/Image mit `containers.text` (Blue), RX/TX eigene Farben
- **Go:** `app.go:1321-1332` — kein Styling
- **Fix:** lipgloss.Foreground für Name/ID/Image und separate Farben für RX/TX
- **Status:** [x] FIXED
### H3. Command-Farben falsch
- **Source:** Main UI Review #5
- **Rust:** restart=Magenta, delete=Gray, resume=Blue, start=Green, stop=Red, pause=Yellow
- **Go:** restart/resume/start=Green, stop/delete=Red, pause=Yellow
- **Fix:** Jeder Command eigene Farbe
- **Status:** [x] FIXED
### H4. Layout-Proportionen falsch
- **Source:** Main UI Review #17
- **Rust:** `ui/mod.rs:400` — 75%/25% (oben/unten)
- **Go:** `app.go:1664-1686` — 30% Container, 22% Charts, Rest Logs
- **Fix:** 75%/25% Split, dann Container/Logs innerhalb der 75%
- **Status:** [x] FIXED
### H5. Ports: `::` und `0` statt leer
- **Source:** Charts/Ports Review #15/#16
- **Rust:** `container_state.rs:156-160` — None → leerer String
- **Go:** `app.go:1522-1523``::` für leere IP, `0` für fehlenden Public Port
- **Fix:** Leere Strings statt `::` und `0`
- **Status:** [x] FIXED
### H6. Falsches Highlight-Symbol
- **Source:** Main UI Review #12
- **Rust:** `mod.rs:47``⚪ ` (offener Kreis)
- **Go:** `app.go:1315``●` (gefüllter Kreis, blau)
- **Fix:** `⚪ ` verwenden
- **Status:** [x] FIXED
### H7. Default-Sort fehlt (nach CreatedAt)
- **Source:** Data Model Review #13
- **Rust:** `mod.rs:492-499` — sort by `created` + Name als Tiebreaker
- **Go:** `app.go:829-831` — kein Sort bei `SortNone`
- **Fix:** Bei `SortNone` nach `CreatedAt` sortieren, Name als Tiebreaker
- **Status:** [x] FIXED
### H8. Sort verliert Selection
- **Source:** Data Model Review #6
- **Rust:** `mod.rs:377-387` — re-selects Container by ID nach Sort
- **Go:** `app.go:812-827` — SelectedIdx bleibt gleich, Container an Position ändert sich
- **Fix:** Nach Sort alten Container per ID finden und SelectedIdx aktualisieren
- **Status:** [x] FIXED
### H9. Sort Tiebreaker by Name fehlt
- **Source:** Data Model Review #15
- **Rust:** `mod.rs:437-484``.then_with(|| name.cmp(name))`
- **Go:** `app.go:834-866` — kein Tiebreaker
- **Fix:** Name-Vergleich als Tiebreaker bei Gleichheit
- **Status:** [x] FIXED
### H10. State-Reihenfolge beim Sort falsch
- **Source:** Data Model Review #14
- **Rust:** `container_state.rs:316-327` — Healthy=0, Unhealthy=1, Paused=2, Restarting=3, etc.
- **Go:** iota-Reihenfolge weicht ab (Exited vor Restarting)
- **Fix:** `order()` Methode mit Rust-Mapping
- **Status:** [x] FIXED
### H11. Search-Highlight: ganze Zeile statt Substring
- **Source:** Charts/Ports Review #22
- **Rust:** Substring-Level Highlighting
- **Go:** `app.go:1422-1423` — ganze Zeile gelb
- **Fix:** Nur gematchten Substring highlighten
- **Status:** [x] FIXED
### H12. Help wird als Vollbild gerendert statt Overlay
- **Source:** Popups Review #11
- **Rust:** `mod.rs:477-480` — Overlay auf Base View
- **Go:** `app.go:1175-1177` — ersetzt komplett den View
- **Fix:** Base View rendern, dann Help darüber legen
- **Status:** [x] FIXED
### H13. Log-Zeilen: Byte-Length statt Rune-Length für UTF-8
- **Source:** Main UI Review #28
- **Rust:** `.chars().count()` für Width
- **Go:** `app.go:1409-1414``len(line)` (Bytes)
- **Fix:** `[]rune(line)` oder `runewidth` verwenden
- **Status:** [x] FIXED
### H14. Bandwidth-Chart: Keine historischen Daten gerendert
- **Source:** Charts/Ports Review #6
- **Rust:** `chart_bandwidth.rs:92-109` — zwei Line-Datasets (RX/TX)
- **Go:** `app.go:1493-1508` — nur Text-Labels, kein Sparkline
- **Fix:** Sparklines für RX/TX History hinzufügen
- **Status:** [x] FIXED
### H15. LogHeight Default: 40 statt 75
- **Source:** Popups Review #23
- **Rust:** `gui_state.rs:220` — default `75`
- **Go:** `app.go:213` — default `40`
- **Fix:** Default auf `75` ändern
- **Status:** [x] FIXED
---
## MEDIUM (Verhaltensunterschiede)
### M1. Binate Stats-Sammlung fehlt (keine Deduplizierung)
- **Source:** Docker Integration Review #2
- **Rust:** `docker_data/mod.rs:44-61` — Binate-Enum, Spawn-Deduplizierung
- **Go:** `app.go:1043-1081` — keine Tracking, Duplikate möglich
- **Fix:** `pendingStats map[string]bool` für In-Flight Tracking
- **Status:** [x] FIXED
### M2. Logs: Tail-500 statt inkrementell (since-Timestamp)
- **Source:** Docker Integration Review #9
- **Rust:** `docker_data/mod.rs:256-273``since` Parameter, akkumuliert
- **Go:** `app.go:1091``Tail: "500"`, ersetzt jedes Mal
- **Status:** [x] FIXED — uses `since` for incremental fetching after initial load
### M3. Per-Container Log-Speicher fehlt
- **Source:** Data Model Review #3
- **Rust:** `ContainerItem.logs` — HashSet mit Dedup, per-Container Scroll
- **Go:** `App.LogLines` — einzelner globaler Slice
- **Status:** [x] FIXED — Container.Logs/LogScroll/LogHScroll with save/restore on selection change
### M4. Container-Selbstfilterung (oxker-Container verstecken) fehlt
- **Source:** Docker Integration Review #12, Data Model Review #9
- **Rust:** `docker_data/mod.rs:229-234` — ENTRY_POINT Filter
- **Go:** keine Selbstfilterung
- **Status:** [x] FIXED — respects Config.ShowSelf
### M5. Docker Ping-Check bei Startup fehlt
- **Source:** Docker Integration Review #15
- **Rust:** `main.rs:68-88` — Ping + Error mit Host-Pfad
- **Go:** `app.go:207``docker.New()` Error wird ignoriert
- **Status:** [x] FIXED — Ping + Host in error message
### M6. Filter-Mode: `/` beendet nicht den Modus (wird als Text eingegeben)
- **Source:** Input Review #14
- **Rust:** `input_handler:523-528``/` beendet Filter-Mode
- **Go:** `app.go:570-590``/` wird als Zeichen eingefügt
- **Status:** [x] FIXED (already implemented — `/` in enter case)
### M7. Search-Mode: `#` beendet nicht den Modus
- **Source:** Input Review #15
- **Rust:** `input_handler:409-414``#` beendet Search-Mode
- **Go:** `app.go:598-631``#` wird als Zeichen eingefügt
- **Status:** [x] FIXED (already implemented — `#` in enter case)
### M8. Direkte Action-Keys (s/x/r/d/p/u) existieren in Rust nicht
- **Source:** Input Review #17
- **Rust:** Actions nur über Commands-Panel mit Enter
- **Go:** `app.go:478-489` — direkte Shortcuts
- **Note:** Go-Erweiterung, könnte gewollt sein, aber kollidiert mit Rust-Keybindings
- **Status:** [x] WONTFIX — bewusste Erweiterung, Shortcuts sind nützlich
### M9. Mouse-Scroll-Amount: 3 statt 1 (mit 10x Modifier)
- **Source:** Input Review #19
- **Rust:** `input_handler:828-829` — Scroll by 1 oder 10 mit Ctrl
- **Go:** `app.go:639-644` — hardcoded 3
- **Status:** [x] FIXED (already 1)
### M10. Mouse-Scroll in Inspect-Mode nicht unterstützt
- **Source:** Input Review #20
- **Rust:** `input_handler:810-818` — Mouse-Scroll im Inspect
- **Go:** `handleMouse` hat keinen Inspect-Check
- **Status:** [x] FIXED (via C9)
### M11. Error-Mode: andere Keys nicht blockiert
- **Source:** Input Review #24
- **Rust:** `handle_error` — nur clear/quit Keys
- **Go:** Sort-Keys, Scroll-Keys etc. funktionieren während Error
- **Status:** [x] FIXED (via C8)
### M12. Unknown-State: falsche Commands
- **Source:** Main UI Review #7
- **Rust:** `container_state.rs:452``Unknown => [Delete]` nur
- **Go:** `app.go:1753-1754``Unknown => [start, restart, delete]`
- **Status:** [x] FIXED (via C6)
### M13. Commands-Panel Scroll fehlt
- **Source:** Input Review #5
- **Rust:** `docker_controls_scroll` bewegt Command-Selection
- **Go:** Scroll im Commands-Panel ändert Container-Selection
- **Status:** [x] FIXED (via C7)
### M14. Commands-Panel Breite: fest 14 statt 10%
- **Source:** Main UI Review #4
- **Rust:** `ui/mod.rs:419``Percentage(10)`
- **Go:** `app.go:1278``cmdW := 14`
- **Status:** [x] FIXED — 10% mit min 12
### M15. DockerConnect Error: kein Countdown, kein Auto-Exit
- **Source:** Popups Review #6/#17
- **Rust:** 5-Sekunden Countdown, Auto-Exit, Host-Anzeige
- **Go:** Standard-Error-Popup
- **Status:** [x] FIXED — 5s countdown + auto-exit + host in error message
### M16. Delete-Dialog: Name nicht bold/highlighted
- **Source:** Popups Review #1
- **Rust:** `delete_confirm.rs:44-53` — Name bold + highlighted
- **Go:** `app.go:1542` — einfacher String
- **Status:** [x] FIXED
### M17. Help-View: stark reduzierter Inhalt
- **Source:** Popups Review #12
- **Rust:** ~570 Zeilen, Logo, Version, Config-Pfad, Timezone, alle Keys
- **Go:** ~20 Zeilen, nur Basis-Keys
- **Status:** [x] FIXED — erweitert mit Config-Pfad, Panel-Info, vollständiger Keybinding-Liste
### M18. Container-Name: führender `/` wird nicht entfernt
- **Source:** Charts/Ports Review #30
- **Rust:** `inspect.rs:35-38` — Strip `/` Prefix
- **Go:** Name direkt verwendet
- **Status:** [x] FIXED (already done via TrimPrefix)
### M19. Inspect: Wrapping statt Truncation
- **Source:** Charts/Ports Review #29
- **Rust:** `inspect.rs:133``Wrap { trim: false }`
- **Go:** `app.go:1603` — Truncation
- **Status:** [x] FIXED
### M20. Spaltenbreiten fest statt dynamisch
- **Source:** Main UI Review #1, Data Model Review #17
- **Rust:** `mod.rs:845-879` — max Width pro Spalte aus Daten
- **Go:** `app.go:1264-1273` — feste Prozent-Widths
- **Status:** [x] FIXED — dynamic widths based on container data
### M21. State-Icons: andere Unicode-Zeichen
- **Source:** Main UI Review #24
- **Rust:** `✓`, `✖`, `॥`, `↻`, `!`, `?`
- **Go:** `✔`, `✖`, `‖`, `↻`, `!`, `?`
- **Status:** [x] FIXED — ✓ statt ✔
### M22. Search-Navigation: Ctrl+N/P statt Down/Up
- **Source:** Input Review #16
- **Rust:** Down/Up Arrow für nächsten/vorherigen Match
- **Go:** Ctrl+N/Ctrl+P
- **Status:** [x] FIXED (already supports both Down/Up and Ctrl+N/P)
### M23. Log-Search Case-Sensitivity nicht konfigurierbar
- **Source:** Data Model Review #19
- **Rust:** `config.log_search_case_sensitive`
- **Go:** immer case-insensitive
- **Status:** [x] FIXED — respects Config.LogSearchCaseSensitive
### M24. Filter wraps around (Go), Rust stoppt an Grenzen
- **Source:** Data Model Review #16
- **Rust:** `FilterBy::next()/prev()` gibt None an Grenzen
- **Go:** Modulo-Wrapping
- **Status:** [x] FIXED — stoppt an Grenzen
### M25. Overlay-Popups: `base` Parameter wird ignoriert
- **Source:** Popups Review #16
- **Go:** `overlayError(base)`, `overlayInfo(base)` — base wird nie verwendet
- **Status:** [x] FIXED — overlay placement with transparent bg
---
## LOW (Kosmetische/Konfigurationsunterschiede)
### L1. Alle Farben hardcoded statt aus Config
- Betrifft: alle Panels, Popups, Charts, Headers
- Config `AppColors` existiert, wird aber nie gelesen
- **Status:** [x] FIXED — colorOr() resolver, borders/commands/state colors from config
### L2. Custom Keymaps nicht implementiert
- Config `Keymap` existiert, wird aber nie verwendet
- Alle Keys sind hardcoded Strings
- **Status:** [x] FIXED — keyMatch() checks config keymap with hardcoded fallbacks
### L3. Mouse-Capture Toggle (`m` Key) fehlt
- **Source:** Input Review #1
- **Status:** [x] FIXED
### L4. Force-Redraw Key (`f`) fehlt
- **Source:** Input Review #2
- **Status:** [x] FIXED — tea.ClearScreen
### L5. Container-Name/Image Truncation bei 30 Zeichen
- **Source:** Data Model Review #18
- **Status:** [x] FIXED (handled by dynamic column widths M20)
### L6. CpuStats Epsilon: 0.001 statt 0.01
- **Source:** Data Model Review #24
- **Status:** [x] FIXED (already 0.001)
### L7. fmtRate fehlt GB/s Tier
- **Source:** Data Model Review #23
- **Status:** [x] FIXED
### L8. InspectData: Name/ID nicht beim Inspizieren gespeichert
- **Source:** Data Model Review #21
- **Status:** [x] FIXED (already shown in viewInspect title)
### L9. Scroll-Speed Modifier: Shift statt Ctrl
- **Source:** Input Review #3
- **Status:** [x] FIXED (already uses J/K = Shift+j/k)
### L10. Horizontal-Scroll in Search-Mode fehlt
- **Source:** Input Review #8
- **Status:** [x] FIXED — left/right in search mode
### L11. Header: "show help" → "exit help" Toggle fehlt
- **Source:** Main UI Review #19
- **Status:** [x] FIXED
### L12. Header: `↓ rx` / `↑ tx` Pfeile existieren nicht in Rust
- **Source:** Main UI Review #20
- **Status:** [x] FIXED — removed arrows
### L13. Charts: State-abhängige Farben fehlen
- **Source:** Charts/Ports Review #4/#8
- **Status:** [x] FIXED — dim color for non-running states
### L14. Charts: Y-Achse Max-Wert fehlt
- **Source:** Charts/Ports Review #2
- **Status:** [x] FIXED — max label shown
### L15. Filter-Bar: kein dediziertes UI (nur inline Text)
- **Source:** Charts/Ports Review #17/#18
- **Status:** [x] FIXED — dedicated styled filter bar
### L16. Search-Bar: kein dediziertes UI (nur inline Text)
- **Source:** Charts/Ports Review #20/#21
- **Status:** [x] FIXED — dedicated styled search bar
### L17. Ports: Title nicht zentriert, nicht als Border-Title
- **Source:** Charts/Ports Review #31
- **Status:** [x] FIXED — centered title
### L18. Inspect: Scroll-Clamping fehlt
- **Source:** Charts/Ports Review #27
- **Status:** [x] FIXED — clampInspectScroll()
### L19. "No containers" Message fehlt
- **Source:** Main UI Review #29
- **Status:** [x] FIXED
### L20. Minimum Terminal-Size Handling fehlt
- **Source:** Main UI Review #30
- **Status:** [x] FIXED — 60x10 minimum with message
### L21. Logs: "parsing logs" Init-State fehlt
- **Source:** Main UI Review #14
- **Status:** [x] FIXED
### L22. Logs: scroll_padding fehlt
- **Source:** Main UI Review #15
- **Status:** [x] FIXED — auto-follow when at bottom
### L23. Logs: color_logs Toggle fehlt
- **Source:** Main UI Review #16
- **Status:** [x] FIXED — stripAnsi when ColorLogs=false
### L24. appendMax Slice-Leak (kein Ring-Buffer)
- **Source:** Data Model Review #22
- **Status:** [x] FIXED — in-place copy instead of reslice
### L25. Delete-Dialog: Container-Not-Found Guard fehlt
- **Source:** Popups Review #2
- **Status:** [x] FIXED — guard + default name
### L26. Network: alle Interfaces statt nur eth0
- **Source:** Docker Integration Review #3
- **Note:** Go-Verhalten ist besser, bewusste Abweichung
- **Status:** [x] WONTFIX — bewusste Verbesserung
-2
View File
@@ -1,2 +0,0 @@
[default.extend-words]
ratatui = "ratatui"
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Build script for Oxker Go TUI
# This script builds the Go application
set -e
cd "$(dirname "$0")"
echo "Building Oxker..."
go build -o oxker ./cmd/oxker
echo "Build complete: ./oxker"
echo "Running go vet..."
go vet ./...
echo "Running go mod tidy..."
go mod tidy
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"github.com/oxker/oxker/internal/config"
)
// CLIArgs represents parsed command-line arguments
type CLIArgs struct {
DockerIntervalMs int
RawLogs bool
ColorLogs bool
ShowTimestamp bool
ShowSelf bool
DebugMode bool
ConfigFile string
Host string
NoStdErr bool
SaveDir string
Timezone string
UseCLI bool
}
// ParseCLIArgs parses command-line arguments
func ParseCLIArgs() (*CLIArgs, error) {
args := &CLIArgs{}
// Define flags
flag.IntVar(&args.DockerIntervalMs, "d", 1000, "Minimum update interval for docker information in milliseconds")
flag.BoolVar(&args.RawLogs, "r", false, "Show raw logs (no ANSI formatting)")
flag.BoolVar(&args.ColorLogs, "c", false, "Attempt to color the logs")
flag.BoolVar(&args.ShowTimestamp, "t", false, "Remove timestamps from each log entry")
flag.BoolVar(&args.ShowSelf, "s", false, "Display the oxker container")
flag.BoolVar(&args.DebugMode, "g", false, "No TUI, debugging mode")
flag.StringVar(&args.ConfigFile, "config-file", "", "Location of config file (TOML/JSON/JSONC)")
flag.StringVar(&args.Host, "host", "", "Connect to Docker with a custom hostname")
flag.BoolVar(&args.NoStdErr, "no-stderr", false, "Do not include stderr output in logs")
flag.StringVar(&args.SaveDir, "save-dir", "", "Save exported logs into a custom directory")
flag.StringVar(&args.Timezone, "timezone", "", "Display Docker logs timestamps in a given timezone")
flag.BoolVar(&args.UseCLI, "use-cli", false, "Use Docker CLI when exec-ing into a container")
flag.Parse()
// Validate docker interval
if args.DockerIntervalMs < 1 {
return nil, fmt.Errorf("docker interval must be greater than 0")
}
// Handle conflicting log options
if args.RawLogs && args.ColorLogs {
args.RawLogs = false
}
return args, nil
}
// LoadConfig loads configuration from file or returns defaults
func LoadConfig(cliArgs *CLIArgs) (*config.Config, error) {
cfg := config.NewConfig()
// If config file specified, try to load it
if cliArgs.ConfigFile != "" {
path := cliArgs.ConfigFile
// Check if absolute or relative path
if !filepath.IsAbs(path) {
// Try relative to current directory
fullPath := filepath.Join(".", path)
if err := config.LoadConfigPath(fullPath, cfg); err != nil {
// Try relative to config directory
home, _ := os.UserHomeDir()
fullPath = filepath.Join(home, ".config", "oxker", path)
if err := config.LoadConfigPath(fullPath, cfg); err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
}
} else {
if err := config.LoadConfigPath(path, cfg); err != nil {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
}
}
// Override with CLI args
if cliArgs.DockerIntervalMs > 0 {
cfg.DockerIntervalMs = cliArgs.DockerIntervalMs
}
cfg.RawLogs = cliArgs.RawLogs
cfg.ColorLogs = cliArgs.ColorLogs
cfg.ShowTimestamp = !cliArgs.ShowTimestamp
cfg.UseCLI = cliArgs.UseCLI
cfg.GUI = !cliArgs.DebugMode
if cliArgs.Host != "" {
cfg.Host = cliArgs.Host
}
cfg.DirConfig = cliArgs.ConfigFile
return cfg, nil
}
// ShowDebugInfo displays information in debug mode (no TUI)
func ShowDebugInfo(containers interface{}) {
// Debug mode - display basic information without TUI
fmt.Println("Oxker Debug Mode - Container Information")
fmt.Println("=========================================")
// TODO: Implement debug mode display
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/oxker/oxker/internal/app"
)
func main() {
// Parse command-line arguments
cliArgs, err := ParseCLIArgs()
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing CLI args: %v\n", err)
os.Exit(1)
}
// Load configuration
cfg, err := LoadConfig(cliArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Debug mode - no TUI
if !cfg.GUI {
ShowDebugInfo(nil)
return
}
// Create and start the Bubble Tea program
// AltScreen and MouseMode are now controlled via View() return value
p := tea.NewProgram(app.New(cfg))
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"testing"
)
func TestAppStart(t *testing.T) {
// Test that the application can be instantiated
// This is a simple smoke test to verify the app compiles and starts
// Parse default CLI args
cliArgs, err := ParseCLIArgs()
if err != nil {
t.Fatalf("Failed to parse CLI args: %v", err)
}
if cliArgs == nil {
t.Fatal("CLI args should not be nil")
}
// Test config loading
cfg, err := LoadConfig(cliArgs)
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
if cfg == nil {
t.Fatal("Config should not be nil")
}
// Test that debug mode works
cfg.GUI = false
if !cfg.GUI {
// Debug mode - should not error
}
// Test that GUI mode is enabled by default
cfg.GUI = true
if !cfg.GUI {
t.Fatal("GUI mode should be enabled")
}
}
-30
View File
@@ -1,30 +0,0 @@
<p align="center">
<img src='https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/logo.svg' width='100px'/>
<h1 align="center">oxker</h1>
<div align="center">
A simple tui to view & control docker containers
</div>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/mrjackwills/oxer/main/.github/demo_01.webp" target='_blank' rel='noopener noreferrer'>
<img src='https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/demo_01.webp' width='450px'/>
</a>
<br>
<sub>
<a href="https://raw.githubusercontent.com/mrjackwills/oxker/main/.github/screenshot_01.png" target='_blank' rel='noopener noreferrer'>
link to alternative screenshot
</a>
</sub>
</p>
## Run
Images built for `linux/amd64`, `linux/arm64`, and `linux/arm/v6`
`docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always mrjackwills/oxker`
## Help
visit the <a href="https://github.com/mrjackwills/oxker" target='_blank' rel='noopener noreferrer'>Github repo</a>
-61
View File
@@ -1,61 +0,0 @@
#############
## Builder ##
#############
FROM --platform=$BUILDPLATFORM rust:slim AS builder
ARG TARGETARCH
# These are build platform dependant, but will be ignored if not needed
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="aarch64-linux-gnu-gcc"
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-lgcc"
ENV CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER="arm-linux-gnueabihf-ld"
COPY ./containerised/target.sh .
RUN chmod +x ./target.sh && ./target.sh
RUN apt-get update && apt-get install $(cat /.compiler) -y
WORKDIR /usr/src
# Create blank project
RUN cargo new oxker
# We want dependencies cached, so copy those first
COPY Cargo.* /usr/src/oxker/
# Set the working directory
WORKDIR /usr/src/oxker
# Install target platform (Cross-Compilation)
RUN rustup target add $(cat /.target)
# This is a dummy build to get the dependencies cached - probably not needed - as run via a github action
RUN cargo build --target $(cat /.target) --release
# Now copy in the rest of the sources
COPY src /usr/src/oxker/src/
# Touch main.rs to prevent cached release build
RUN touch /usr/src/oxker/src/main.rs
# This is the actual application build
RUN cargo build --release --target $(cat /.target)
RUN cp /usr/src/oxker/target/$(cat /.target)/release/oxker /
#############
## Runtime ##
#############
FROM scratch
# Set an ENV to indicate that we're running in a container
ENV OXKER_RUNTIME=container
COPY --from=builder /oxker /app/
# Run the application
# this is used in the application itself so DO NOT EDIT
ENTRYPOINT [ "/app/oxker"]
-39
View File
@@ -1,39 +0,0 @@
#############
## Runtime ##
#############
FROM scratch
# Set env that we're running in a container
ENV OXKER_RUNTIME=container
# Copy application binary from builder image
COPY ./target/x86_64-unknown-linux-musl/release/oxker /app/
# Run the application
# this is used in the application itself, to stop itself show when running from a docker container, so DO NOT EDIT
ENTRYPOINT [ "/app/oxker"]
# Dev build for testing
# docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev
# Dev build one liner, x86 host
# docker image prune -a; cargo build --release --target x86_64-unknown-linux-musl && docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev
# Buildx command to build musl version for all three platforms, should probably be executed in create_release
# docker buildx create --use
# docker buildx build --platform linux/arm/v6,linux/arm64,linux/amd64 -t oxker_dev_all -o type=tar,dest=/tmp/oxker_dev_all.tar -f containerised/Dockerfile .
### Build docker files and save to .tar file
# docker build --platform linux/amd64 -t oxker_amd64 -f containerised/Dockerfile .; docker save -o ./oxker_amd64.tar oxker_amd64
# docker load -i oxker_amd64.tar
# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_amd64
# docker build --platform linux/arm64 -t oxker_arm64 -f containerised/Dockerfile .; docker save -o ./oxker_arm64.tar oxker_arm64
# docker load -i oxker_arm64.tar
# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_arm64
# docker build --platform linux/arm/v6 -t oxker_armv6 -f containerised/Dockerfile .; docker save -o ./oxker_armv6.tar oxker_armv6
# docker load -i oxker_armv6.tar
# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_armv6
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# Used in Docker build to set platform dependent variables
case $TARGETARCH in
"amd64")
echo "x86_64-unknown-linux-musl" >/.target
echo "" >/.compiler
;;
"arm64")
echo "aarch64-unknown-linux-musl" >/.target
echo "gcc-aarch64-linux-gnu" >/.compiler
;;
"arm")
echo "arm-unknown-linux-musleabihf" >/.target
echo "gcc-arm-linux-gnueabihf" >/.compiler
;;
esac
-541
View File
@@ -1,541 +0,0 @@
#!/bin/bash
# rust create_release v0.6.3
# 2025-09-20
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
}
# Check that dialog is installed
if ! [ -x "$(command -v dialog)" ]; then
error_close "dialog is not installed"
fi
# $1 string - question to ask
# $1 question to ask
# Ask a yes no question, only accepts `y` or `n` as a valid answer, returns 0 for yes, 1 for no
ask_yn() {
while true; do
printf "\n%b%s? [y/N]:%b " "${GREEN}" "$1" "${RESET}"
read -r answer
if [[ "$answer" == "y" ]]; then
return 0
elif [[ "$answer" == "n" ]]; then
return 1
else
echo -e "${RED}\nPlease enter 'y' or 'n'${RESET}"
fi
done
}
# ask continue, or quit
ask_continue() {
if ! ask_yn "continue"; then
exit
fi
}
# semver major update
update_major() {
local bumped_major
bumped_major=$((MAJOR + 1))
echo "${bumped_major}.0.0"
}
# semver minor update
update_minor() {
local bumped_minor
bumped_minor=$((MINOR + 1))
MINOR=bumped_minor
echo "${MAJOR}.${bumped_minor}.0"
}
# semver patch update
update_patch() {
local bumped_patch
bumped_patch=$((PATCH + 1))
PATCH=bumped_patch
echo "${MAJOR}.${MINOR}.${bumped_patch}"
}
# Get the url of the github repo, strip .git from the end of it
get_git_remote_url() {
GIT_REPO_URL="$(git config --get remote.origin.url | sed 's/\.git$//')"
}
# 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}"
if ask_yn "accept release body"; then
update_release_body_and_changelog "$RELEASE_BODY_TEXT"
else
exit
fi
}
# Edit the release-body to include new lines 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"
# Put new changelog entries into release-body, add link to changelog
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
# Add subheading with release version and date of release
echo -e "# <a href='${GIT_REPO_URL}/releases/tag/${NEW_TAG_WITH_V}'>${NEW_TAG_WITH_V}</a>\n${DATE_SUBHEADING}${CHANGELOG_ADDITION}$(cat CHANGELOG.md)" >CHANGELOG.md
# Update changelog to add links to commits [hex:8](url_with_full_commit)
# "[aaaaaaaaaabbbbbbbbbbccccccccccddddddddd]" -> "[aaaaaaaa](https:/www.../commit/aaaaaaaaaabbbbbbbbbbccccccccccddddddddd)"
sed -i -E "s=(\s)\[([0-9a-f]{8})([0-9a-f]{32})\]= [\2](${GIT_REPO_URL}/commit/\2\3)=g" CHANGELOG.md
# Update changelog to add links to closed issues
# "closes #1" -> "closes [#1](https:/www.../issues/1)""
sed -i -r -E "s=closes \#([0-9]+)=closes [#\1](${GIT_REPO_URL}/issues/\1)=g" CHANGELOG.md
# Update changelog to add links to merged PR's
# "merges #1" -> "merges [#1](https:/www.../pull/1)""
sed -i -r -E "s=merges \#([0-9]+)=merges [#\1](${GIT_REPO_URL}/pull/\1)=g" CHANGELOG.md
}
# update version in cargo.toml, to match selected current version
update_version_number_in_files() {
sed -i "s|^version = .*|version = \"${MAJOR}.${MINOR}.${PATCH}\"|" Cargo.toml
}
# Work out the current version, based on git tags
# create new semver version based on user input
# Set MAJOR MINOR PATCH
check_tag() {
LATEST_TAG=$(git describe --tags "$(git rev-list --tags --max-count=1)")
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
OP_MAJOR="major___v$(update_major)"
OP_MINOR="minor___v$(update_minor)"
OP_PATCH="patch___v$(update_patch)"
OPTIONS=("$OP_MAJOR" "$OP_MINOR" "$OP_PATCH")
select choice in "${OPTIONS[@]}"; do
case $choice in
"$OP_MAJOR")
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
break
;;
"$OP_MINOR")
MINOR=$((MINOR + 1))
PATCH=0
break
;;
"$OP_PATCH")
PATCH=$((PATCH + 1))
break
;;
*)
error_close "invalid option $REPLY"
;;
esac
done
}
# run all tests
cargo_test() {
cargo test -- --test-threads=1
ask_continue
}
# Simulate publishing to crates.io
cargo_publish_dry_run() {
echo -e "${PURPLE}cargo publish --dry-run${RESET}"
cargo publish --dry-run
ask_continue
}
# Check to see if cross is installed - if not then install
check_cross() {
if ! [ -x "$(command -v cross)" ]; then
echo -e "${GREEN}cargo install cross${RESET}"
cargo install cross
fi
}
# Build, using cross-rs, for linux x86 musl
cross_build_x86_linux() {
check_cross
echo -e "${YELLOW}cross build --target x86_64-unknown-linux-musl --release${RESET}"
cross build --target x86_64-unknown-linux-musl --release
}
# Build, using cross-rs, for linux arm64 musl
cross_build_aarch64_linux() {
check_cross
echo -e "${YELLOW}cross build --target aarch64-unknown-linux-musl --release${RESET}"
cross build --target aarch64-unknown-linux-musl --release
}
# Build, using cross-rs, for linux armv6 musl
cross_build_armv6_linux() {
check_cross
echo -e "${YELLOW}cross build --target arm-unknown-linux-musleabihf --release${RESET}"
cross build --target arm-unknown-linux-musleabihf --release
}
# Build, using cross-rs, for windows x86
cross_build_x86_windows() {
check_cross
echo -e "${YELLOW}cross build --target x86_64-pc-windows-gnu --release${RESET}"
cross build --target x86_64-pc-windows-gnu --release
}
# Build, using zig-build, for Apple silicon
zig_build_aarch64_apple() {
# mkdir /workspace/oxker/target
echo -e "${YELLOW}docker run --rm -v $(pwd):/io -w /io ghcr.io/rust-cross/cargo-zigbuild bash -e -c 'rustup update stable && rustup default stable && rustup target add aarch64-apple-darwin && cargo zigbuild --release --target aarch64-apple-darwin${RESET}"
docker run --rm -v "$(pwd):/io" -w /io \
ghcr.io/rust-cross/cargo-zigbuild \
bash -ec 'rustup update stable && rustup default stable && rustup target add aarch64-apple-darwin && cargo zigbuild --release --target aarch64-apple-darwin'
if ask_yn "sudo chown $(pwd)/target"; then
echo -e "${YELLOW}sudo chown -R vscode:vscode $(pwd)/target${RESET}"
sudo chown -R vscode:vscode "$(pwd)/target"
fi
}
cargo_clean() {
echo -e "${YELLOW}cargo clean${RESET}"
cargo clean
}
# Build all releases that GitHub workflow would
# This will download GB's of docker images
# $1 is 0 or 1, if 1 won't run ask_continue
cross_build_all() {
if ask_yn "cargo clean"; then
cargo_clean
fi
skip_confirm=$1
cross_build_armv6_linux
[ "$skip_confirm" -ne 1 ] && ask_continue
cross_build_aarch64_linux
[ "$skip_confirm" -ne 1 ] && ask_continue
cross_build_x86_linux
[ "$skip_confirm" -ne 1 ] && ask_continue
cross_build_x86_windows
[ "$skip_confirm" -ne 1 ] && ask_continue
zig_build_aarch64_apple
[ "$skip_confirm" -ne 1 ] && ask_continue
}
# $1 text to colourise
release_continue() {
echo -e "\n${PURPLE}$1${RESET}"
ask_continue
}
# Check repository for typos
check_typos() {
echo -e "\n${PURPLE}check typos${RESET}"
typos
ask_continue
}
# Make sure the unused lint isn't used
check_allow_unused() {
matches_any=$(find . -type d \( -name .git -o -name target \) -prune -o -type f -exec grep -lE '^#!\[allow\(unused\)\]$' {} +)
matches_cargo=$(grep "^unused = \"allow\"" ./Cargo.toml)
if [ -n "$matches_any" ]; then
echo "\"#[allow(unused)]\" in ${matches_any}"
ask_continue
elif [ -n "$matches_cargo" ]; then
echo "\"unused = \"allow\"\" in Cargo.toml"
ask_continue
fi
}
# build container for amd64 platform
build_container_amd64() {
echo -e "${YELLOW}docker build --platform linux/amd64 --no-cache -t oxker_amd64 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_amd64.tar oxker_amd64${RESET}"
docker build --platform linux/amd64 --no-cache -t oxker_amd64 -f containerised/Dockerfile .
docker save -o /tmp/oxker_amd64.tar oxker_amd64
}
# build container for aarm64 platform
build_container_arm64() {
echo -e "${YELLOW}docker build --platform linux/arm64 --no-cache -t oxker_arm64 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_arm64.tar oxker_arm64${RESET}"
docker build --platform linux/arm64 --no-cache -t oxker_arm64 -f containerised/Dockerfile .
docker save -o /tmp/oxker_arm64.tar oxker_arm64
}
# build container for armv6 platform
build_container_armv6() {
echo -e "${YELLOW}docker build --platform linux/arm/v6 --no-cache -t oxker_armv6 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_armv6.tar oxker_armv6${RESET}"
docker build --platform linux/arm/v6 --no-cache -t oxker_armv6 -f containerised/Dockerfile .
docker save -o /tmp/oxker_armv6.tar oxker_armv6
}
# Build all the containers, this get executed in the github action
# $1 is 0 or 1, if 1 won't run ask_continue
build_container_all() {
skip_confirm=$1
build_container_amd64
[ "$skip_confirm" -ne 1 ] && ask_continue
build_container_arm64
[ "$skip_confirm" -ne 1 ] && ask_continue
build_container_armv6
[ "$skip_confirm" -ne 1 ] && ask_continue
}
# Full flow to create a new release
release_flow() {
check_allow_unused
check_typos
check_git
get_git_remote_url
cargo_test
cross_build_all 0
build_container_all 0
cargo_publish_dry_run
cd "${CWD}" || error_close "Can't find ${CWD}"
check_tag
NEW_TAG_WITH_V="v${MAJOR}.${MINOR}.${PATCH}"
printf "\nnew tag chosen: %s\n\n" "${NEW_TAG_WITH_V}"
RELEASE_BRANCH=release-$NEW_TAG_WITH_V
echo -e
ask_changelog_update
release_continue "checkout ${RELEASE_BRANCH}"
git checkout -b "$RELEASE_BRANCH"
release_continue "update_version_number_in_files"
update_version_number_in_files
echo -e "\ncargo fmt"
cargo fmt
echo -e "\n${PURPLE}cargo check${RESET}\n"
cargo check
release_continue "git add ."
git add .
release_continue "git commit -m \"chore: release \"${NEW_TAG_WITH_V}\""
git commit -m "chore: release ${NEW_TAG_WITH_V}"
release_continue "git checkout main"
git checkout main
echo -e "${PURPLE}git pull origin main${RESET}"
git pull origin main
echo -e "${PURPLE}git merge --no-ff \"${RELEASE_BRANCH}\" -m \"chore: merge ${RELEASE_BRANCH} into main\"${RESET}"
git merge --no-ff "$RELEASE_BRANCH" -m "chore: merge ${RELEASE_BRANCH} into main"
echo -e "\n${PURPLE}cargo check${RESET}\n"
cargo check
release_continue "git tag -am \"${RELEASE_BRANCH}\" \"$NEW_TAG_WITH_V\""
git tag -am "${RELEASE_BRANCH}" "$NEW_TAG_WITH_V"
release_continue "git push --atomic origin main \"$NEW_TAG_WITH_V\""
git push --atomic origin main "$NEW_TAG_WITH_V"
release_continue "git checkout dev"
git checkout dev
release_continue "git merge --no-ff main -m \"chore: merge main into dev\""
git merge --no-ff main -m "chore: merge main into dev"
release_continue "git push origin dev"
git push origin dev
release_continue "git branch -d \"$RELEASE_BRANCH\""
git branch -d "$RELEASE_BRANCH"
}
build_choice() {
cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
options=(
1 "x86 musl linux" off
2 "aarch64 musl linux" off
3 "armv6 musl linux" off
4 "aarch64 apple" off
5 "x86 windows" off
6 "all" off
7 "all automatic" off
)
choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
exitStatus=$?
if [ $exitStatus -ne 0 ]; then
exit
fi
for choice in $choices; do
case $choice in
0)
exit
;;
1)
cross_build_x86_linux
exit
;;
2)
cross_build_aarch64_linux
exit
;;
3)
cross_build_armv6_linux
exit
;;
4)
zig_build_aarch64_apple
exit
;;
5)
cross_build_x86_windows
exit
;;
6)
cross_build_all 0
exit
;;
7)
cross_build_all 1
exit
;;
esac
done
}
build_container_choice() {
cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
options=(
1 "x86 " off
2 "aarch64" off
3 "armv6" off
4 "all" off
5 "all automatic" off
)
choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
exitStatus=$?
if [ $exitStatus -ne 0 ]; then
exit
fi
for choice in $choices; do
case $choice in
0)
exit
;;
1)
build_container_amd64
exit
;;
2)
build_container_arm64
exit
;;
3)
build_container_armv6
exit
;;
4)
build_container_all 0
exit
;;
5)
build_container_all 1
exit
;;
esac
done
}
main() {
cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
options=(
1 "test" off
2 "release" off
3 "build" off
4 "docker builds" off
)
choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
exitStatus=$?
if [ $exitStatus -ne 0 ]; then
exit
fi
for choice in $choices; do
case $choice in
0)
exit
;;
1)
cargo_test
main
break
;;
2)
release_flow
break
;;
3)
build_choice
main
break
;;
4)
build_container_choice
main
break
;;
esac
done
}
main
-17
View File
@@ -1,17 +0,0 @@
# Use an official lightweight image as a base
FROM alpine:latest
# Install a simple utility (e.g., curl) to run as a health check
RUN apk --no-cache add curl speedtest-cli
# Create a dummy file that we will use in our health check
RUN touch /tmp/healthy
# Define a simple health check
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD [ ! -f /tmp/healthy ] || exit 1
# Start a basic loop that keeps the container running
CMD ["sh", "-c", "speedtest-cli; while :; do >&2 echo 'Container is running but will be unhealthy (also printing to stderr)'; sleep 30; done"]
# docker build -t unhealthy-container . -f Dockerfile.unhealthy; docker run -d --name unhealthy unhealthy-container
-58
View File
@@ -1,58 +0,0 @@
networks:
oxker-example-net:
name: oxker-examaple-net
services:
postgres:
image: postgres:18-alpine
container_name: postgres
environment:
- POSTGRES_PASSWORD=never_use_this_password_in_production
ipc: private
restart: always
shm_size: 256MB
networks:
- oxker-example-net
deploy:
resources:
limits:
memory: 1024M
redis:
image: redis:latest
container_name: redis
ipc: private
restart: always
networks:
- oxker-example-net
deploy:
resources:
limits:
memory: 384M
rabbitmq:
image: rabbitmq:3
container_name: rabbitmq
ipc: private
restart: always
networks:
- oxker-example-net
deploy:
resources:
limits:
memory: 512M
some_container:
container_name: some_container
image: some_container
build:
context: .
dockerfile: Dockerfile.unhealthy
ipc: private
restart: always
networks:
- oxker-example-net
deploy:
resources:
limits:
memory: 128M
-372
View File
@@ -1,372 +0,0 @@
{
// Example JSONC config file
// This needs to be renamed to "config.jsonc" ("config.json" will also work, even if the file is actually a jsonc) in order for oxker to automatically load
// oxker will also read .toml and .json files which use the same key/value structure & format as this file
// Every key is optional, with defaults that oxker will choose if missing or invalid
// The `--config-file` cli argument can be used to load configuration files from any readable location
// Docker update interval in ms, minimum effectively 1000
"docker_interval": 1000,
// Attempt to colorize the logs, conflicts with "raw"
"color_logs": false,
// Show raw logs, default is to remove ansi formatting, conflicts with "color"
"raw_logs": false,
// Show self (the oxker container) when running as a docker container
"show_self": false,
// Show std_err in logs
"show_std_err": true,
// Show a timestamp for every log entry
"show_timestamp": true,
// Don't draw gui - for debugging - mostly pointless
"gui": true,
// Docker host location. Will take priority over a DOCKER_HOST env.
// "host": "/var/run/docker.sock",
// Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.01234567
// *Should* accept any valid strftime string up to 32 chars, see https://strftime.org/
"timestamp_format": "%Y-%m-%dT%H:%M:%S.%8f",
// Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC
"timezone": "Etc/UTC",
// Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
// "save_dir": "$HOME",
// Force use of docker cli when execing into containers, honestly mostly pointless
"use_cli": false,
// Show the logs section - this can be changed during operation with the log_section_toggle key
"show_logs": true,
// Use case-sensitive matching for logs
"log_search_case_sensitive": true,
//////////////////
// Custom Keymap //
//////////////////
// Available keys are;
// 1) a-z and A-Z
// 2) 0-9
// WARNING if using the \ key, it needs to be escaped, e.g. "log_section_toggle": ["\\"]
// 3) / \ , . # ' [ ] ; = -
// 3) F1-F12
// 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
// Each definition can have two keys associated with it
// WARNING "scroll_many" only accepts control, alt, shift, with no secondary option
// If any key clashes are found, oxker will revert to it's default keymap
"keymap": {
// Clear any popup boxes, filter panel, or help panel
"clear": [
"c",
"esc"
],
// Cancel delete - clear also works here
"delete_deny": [
"n"
],
// Confirm Delete
"delete_confirm": [
"y"
],
// Exec into the selected container
"exec": [
"e"
],
// Enter filter mode
"filter_mode": [
"/",
"F1"
],
// Enter log search mode
"log_search_mode": [
"#"
],
// Quit at anytime
"quit": [
"q"
],
// Save logs of selected container to file on disk
"save_logs": [
"s"
],
// Scroll down a list by one item
"scroll_down": [
"down",
"j"
],
// Scroll down to the end of a list
"scroll_end": [
"end"
],
// Modifier to scroll by 10 lines instead of one, used in conjunction with scroll_up/scroll_down
"scroll_many": [
"control"
],
// Scroll up to the start of a list
"scroll_start": [
"home"
],
// Scroll up a list by one item
"scroll_up": [
"up",
"k"
],
// Horizontal scroll of the logs
"scroll_forward": [
"right"
],
"scroll_back": [
"left"
],
// Select next panel
"select_next_panel": [
"tab"
],
// Select previous panel
"select_previous_panel": [
"backtab"
],
// Sort the containers based on specific column
"sort_by_name": [
"1"
],
"sort_by_state": [
"2"
],
"sort_by_status": [
"3"
],
"sort_by_cpu": [
"4"
],
"sort_by_memory": [
"5"
],
"sort_by_id": [
"6"
],
"sort_by_image": [
"7"
],
"sort_by_rx": [
"8"
],
"sort_by_tx": [
"9"
],
// Reset the sorted containers
"sort_reset": [
"0"
],
// Toggle the help panel
"toggle_help": [
"h"
],
// Toggle mouse capture
"toggle_mouse_capture": [
"m"
],
// Reduce the height of the logs list section
"log_section_height_decrease": [
"-"
],
// Increase the height of the logs list section
"log_section_height_increase": [
"+"
],
// Toggle visibility of the log section
"log_section_toggle": [
"\\"
],
// Toggle to inspect container screen
"inspect": [
"i"
],
// Force a complete clear & redraw of the screen
"force_redraw": [
"f"
]
},
////////////////////
// Custom Colors //
////////////////////
// Colors be listed as either;
// 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
// 2) Hex values: '#FF0000', case insensitive
// 3) 'reset' for transparency
// Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
"colors": {
// The single line bar at the uppermost of the display. Contains name/state/status headings etc
"headers_bar": {
// Background color of the entire line
"background": "magenta",
// Animated loading icon at the start of the bar
"loading_spinner": "white",
// Text color
"text": "black",
// Text color of a selected header
"text_selected": "gray"
},
// The borders around the selectable panels - Containers, Commands, Logs
"borders": {
// Border when selected
"selected": "lightcyan",
// Border when not selected
"unselected": "grey"
},
// The containers sections, in the future more color customization options should be made available in this section
"containers": {
// The icon use to illustrate which container is currently selected
"icon": "white",
// Background color of panel
"background": "reset",
// At the moment, this will only change the color of the name, id, and image columns
"text": "blue",
// Text color of the RX column
"text_rx": "#FFE9C1",
// Text color of the TX column
"text_tx": "#CD8C8C"
},
// Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
"container_state": {
"dead": "red",
"exited": "red",
"paused": "yellow",
"removing": "lightred",
"restarting": "lightgreen",
"running_healthy": "green",
"running_unhealthy": "#FFB224",
"unknown": "red"
},
// The color the of Docker commands available for each container
"commands": {
// Background color of panel
"background": "reset",
"pause": "yellow",
"restart": "magenta",
"stop": "red",
"delete": "gray",
"resume": "blue",
"start": "green"
},
// The cpu chart
"chart_cpu": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Maximum CPU percentage
"max": "#FFB224",
// Points on the chart
"points": "magenta",
// The charts y-axis
"y_axis": "white"
},
// The memory chart
"chart_memory": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Maximum memory use
"max": "#FFB224",
// Points on the chart
"points": "cyan",
// The charts y-axis
"y_axis": "white"
},
// The ports chart
"chart_ports": {
// Background color of panel
"background": "reset",
// Border color
"border": "white",
// Chart title
"title": "green",
// Private/Public/IP headings
"headings": "yellow",
// Ports & IP listing text
"text": "white"
},
// The bandwidth chart
"chart_bandwidth": {
//Background color of panel
"background": "reset",
// Border color
"border": "white",
// Maximum RX value - again paused & stopped colors not yet customizable
"max_rx": "#FFE9C1",
// # Maximum TX value - again paused & stopped colors not yet customizable
"max_tx": "#CD8C8C",
// RX points on the chart - again paused & stopped colors not yet customizable
"points_rx": "#FFE9C1",
// TX points on the chart - again paused & stopped colors not yet customizable
"points_tx": "#CD8C8C",
// RX title color
"title_rx": "#FFE9C1",
// TX title color
"title_tx": "#CD8C8C",
// The charts y-axis
"y_axis": "white"
},
// The filter panel
"filter": {
// Background color of panel
"background": "reset",
// color of text
"text": "gray",
// background color of the selected filter by item (Name/Image/Status/All)
"selected_filter_background": "gray",
// text color of the selected filter by item (Name/Image/Status/All)
"selected_filter_text": "black",
// Highlighted text color
"highlight": "magenta"
},
// The log search panel
"log_search": {
// Background color of panel
"background": "reset",
// color of text
"text": "gray",
// text color of the buttons text
"button_text": "black",
// Highlighted text color
"highlight": "magenta"
},
// The logs panel, will only be applied if color_logs is false
"logs": {
// Background color of panel
"background": "reset",
// text color
"text": "reset"
},
// The help popup
"popup_help": {
// Background color
"background": "magenta",
// Text color
"text": "black",
// Highlighted text color
"text_highlight": "white"
},
// The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
"popup_info": {
// Background color
"background": "blue",
// Text color
"text": "white"
},
// The delete popup - used to display a confirmation warning when about to delete a container
"popup_delete": {
// Background color
"background": "white",
// Text color
"text": "black",
// Highlighted text color
"text_highlight": "red"
},
// The error popup - hopefully you'll never have to see this
"popup_error": {
// Background color
"background": "red",
// Text color
"text": "white"
}
}
}
-323
View File
@@ -1,323 +0,0 @@
# oxker config file
# oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
# Every key is optional, with defaults that oxker will choose if missing or invalid
# The `--config-file` cli argument can be used to load configuration files from any readable location
# Docker update interval in ms, minimum effectively 1000
docker_interval = 1000
# Attempt to colorize the logs, conflicts with "raw"
color_logs = false
# Show raw logs, default is to remove ansi formatting, conflicts with "color"
raw_logs = false
# Show self (the oxker container) when running as a docker container
show_self = false
# Show std_err in logs
show_std_err = true
# Show a timestamp for every log entry
show_timestamp = true
# Don't draw gui - for debugging - mostly pointless
gui = true
# Docker host location. Will take priority over a DOCKER_HOST env.
# host = "/var/run/docker.sock"
# Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC
timezone = "Etc/UTC"
# Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.012345678Z
# *Should* accept any valid strftime string up to 32 chars, see https://strftime.org/
timestamp_format = "%Y-%m-%dT%H:%M:%S.%8f"
# Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
# save_dir = "$HOME"
# Force use of docker cli when execing into containers, honestly mostly pointless
use_cli = false
# Show the logs section - this can be changed during operation with the log_section_toggle key
show_logs = true
# Use case-sensitive matching for logs
log_search_case_sensitive = true
#################
# Custom Keymap #
#################
# Available keys are;
# 1) a-z and A-Z
# 2) 0-9
# WARNING if using the \ key, it needs to be escaped, e.g. log_section_toggle = ["\\"]
# 3) / \ , . # ' [ ] ; = -
# 3) F1-F12
# 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
# Each definition can have two keys associated with it
# WARNING "scroll_many" only accepts control, alt, shift, with no secondary option
# If any key clashes are found, oxker will revert to it's default keymap
[keymap]
# Clear any popup boxes, filter panel, or help panel
clear = ["c", "esc"]
# Cancel delete - clear also works here
delete_deny = ["n"]
# Confirm Delete
delete_confirm = ["y"]
# Exec into the selected container
exec = ["e"]
# Enter filter mode
filter_mode = ["/", "F1"]
# Enter log search mode
log_search_mode = ["#"]
# Quit at anytime
quit = ["q"]
# Save logs of selected container to file on disk
save_logs = ["s"]
# scroll down a list by one item
scroll_down = ["down", "j"]
# scroll down to the end of a list
scroll_end = ["end"]
# Modifier to scroll by 10 lines instead of one, used in conjunction with scroll_up/scroll_down
scroll_many = ["control"]
# scroll up to the start of a list
scroll_start = ["home"]
# scroll up a list by one item
scroll_up = ["up", "k"]
# Horizontal scroll of the logs
scroll_forward = ["right"]
scroll_back = ["left"]
# Select next panel
select_next_panel = ["tab"]
# Select previous panel
select_previous_panel = ["backtab"]
# Sort the containers based on specific column
sort_by_name = ["1"]
sort_by_state = ["2"]
sort_by_status = ["3"]
sort_by_cpu = ["4"]
sort_by_memory = ["5"]
sort_by_id = ["6"]
sort_by_image = ["7"]
sort_by_rx = ["8"]
sort_by_tx = ["9"]
# Reset the sorted containers
sort_reset = ["0"]
# Toggle the help panel
toggle_help = ["h"]
# Toggle mouse capture
toggle_mouse_capture = ["m"]
# Reduce the height of the logs list section
log_section_height_decrease = ["-"]
log_section_height_increase = ["+"]
# Toggle visibility of the log section
log_section_toggle = ["\\"]
# Toggle to inspect container screen
inspect = ["i"]
# Force a complete clear & redraw of the screen
force_redraw = ["f"]
#################
# Custom Colors #
#################
# Colors be listed as either;
# 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
# 2) Hex values: '#FF0000', case insensitive
# 3) 'reset' for transparency
# Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
# The single line bar at the uppermost of the display. Contains name/state/status headings etc
[colors.headers_bar]
# Background color of the entire line
background = "magenta"
# Animated loading icon at the start of the bar
loading_spinner = "white"
# Text color
text = "black"
# Text color of a selected header
text_selected = "gray"
# The borders around the selectable panels - Containers, Commands, Logs
[colors.borders]
# Border when selected
selected = "lightcyan"
# Border when not selected
unselected = "grey"
# The containers sections, in the future more color customization options should be made available in this section
[colors.containers]
# The icon use to illustrate which container is currently selected - at the moment the TUI library, ratatui, doesn't seem allow changing the color of the highlight symbol
icon = "white"
# Background color of panel
background = "reset"
# At the moment, this will only change the color of the name, id, and image columns
text = "blue"
# Text color of the RX column
text_rx = "#FFE9C1"
# Text color of the TX column
text_tx = "#CD8C8C"
# The logs panel, will only be applied if color_logs is false
[colors.logs]
# Background color of panel
background = "reset"
# text color
text = "reset"
# Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
[colors.container_state]
dead = "red"
exited = "red"
paused = "yellow"
removing = "lightred"
restarting = "lightgreen"
running_healthy = "green"
running_unhealthy = "#FFB224"
unknown = "red"
# The filter panel
[colors.filter]
# Background color of panel
background = "reset"
# color of text
text = "gray"
# background color of the selected filter by item (Name/Image/Status/All)
selected_filter_background = "gray"
# text color of the selected filter by item (Name/Image/Status/All)
selected_filter_text = "black"
# Highlighted text color
highlight = "magenta"
# The log search panel
[colors.log_search]
# Background color of panel
background = "reset"
# color of text
text = "gray"
# text color of the buttons text
button_text = "black"
# Highlighted text color
highlight = "magenta"
# The color the of Docker commands available for each container
[colors.commands]
# Background color of panel
background = "reset"
pause = "yellow"
restart = "magenta"
stop = "red"
delete = "gray"
resume = "blue"
start = "green"
# The cpu chart
[colors.chart_cpu]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped colors not yet customizable - or could just use state color?
title = "green"
# Maximum CPU percentage - again paused & stopped colors not yet customizable
max = "#FFB224"
# Points on the chart - again paused & stopped colors not yet customizable
points = "magenta"
# The charts y-axis
y_axis = "white"
# The memory chart
[colors.chart_memory]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title = "green"
# Maximum memory use - again paused & stopped will use colors.container_state
max = "#FFB224"
# Points on the chart - again paused & stopped will use colors.container_state
points = "cyan"
# The charts y-axis
y_axis = "white"
# The bandwidth chart
[colors.chart_bandwidth]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Maximum RX value - again paused & stopped colors not yet customizable
max_rx = "#FFE9C1"
# Maximum TX value - again paused & stopped colors not yet customizable
max_tx = "#CD8C8C"
# RX points on the chart - again paused & stopped colors not yet customizable
points_rx = "#FFE9C1"
# TX points on the chart - again paused & stopped colors not yet customizable
points_tx = "#CD8C8C"
# TX title color
title_tx = "#FFE9C1"
# RX title color
title_rx = "#CD8C8C"
# The charts y-axis
y_axis = "white"
# The ports chart
[colors.chart_ports]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title = "green"
# Private/Public/IP headings
headings = "yellow"
# Ports & IP listing text
text = "white"
# The help popup
[colors.popup_help]
# Background color
background = "magenta"
# Text color
text = "black"
# Highlighted text color
text_highlight = "white"
# The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
[colors.popup_info]
# Background color
background = "blue"
# Text color
text = "white"
# The delete popup - used to display a confirmation warning when about to delete a container
[colors.popup_delete]
# Background color
background = "white"
# Text color
text = "black"
# Highlighted text color
text_highlight = "red"
# The error popup - hopefully you'll never have to see this
[colors.popup_error]
# Background color
background = "red"
# Text color
text = "white"
+63
View File
@@ -0,0 +1,63 @@
module github.com/oxker/oxker
go 1.24.4
require (
charm.land/bubbletea/v2 v2.0.2
github.com/BurntSushi/toml v1.3.2
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/docker/docker v28.5.2+incompatible
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
github.com/olekukonko/tablewriter v0.0.5
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
+157
View File
@@ -0,0 +1,157 @@
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
UNAME_CMD="$(uname -m)"
case "$UNAME_CMD" in
x86_64) SUFFIX="x86_64" ;;
aarch64) SUFFIX="aarch64" ;;
armv6l) SUFFIX="armv6" ;;
esac
if [ -n "$SUFFIX" ]; then
OXKER_GZ="oxker_linux_${SUFFIX}.tar.gz"
curl -L -O "https://github.com/mrjackwills/oxker/releases/latest/download/${OXKER_GZ}"
tar xzvf "${OXKER_GZ}" oxker
install -Dm 755 oxker -t "${HOME}/.local/bin"
rm "${OXKER_GZ}" oxker
fi
+3255
View File
File diff suppressed because it is too large Load Diff
+645
View File
@@ -0,0 +1,645 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
type AppColors struct {
Borders Borders `toml:"borders,omitempty" json:"borders,omitempty"`
ChartCPU ChartCPU `toml:"chart_cpu,omitempty" json:"chart_cpu,omitempty"`
ChartMemory ChartMemory `toml:"chart_memory,omitempty" json:"chart_memory,omitempty"`
ChartBandwidth ChartBandwidth `toml:"chart_bandwidth,omitempty" json:"chart_bandwidth,omitempty"`
ChartPorts ChartPorts `toml:"chart_ports,omitempty" json:"chart_ports,omitempty"`
Commands Commands `toml:"commands,omitempty" json:"commands,omitempty"`
ContainerState ContainerState `toml:"container_state,omitempty" json:"container_state,omitempty"`
Containers Containers `toml:"containers,omitempty" json:"containers,omitempty"`
LogSearch LogSearch `toml:"log_search,omitempty" json:"log_search,omitempty"`
Filter Filter `toml:"filter,omitempty" json:"filter,omitempty"`
HeadersBar HeadersBar `toml:"headers_bar,omitempty" json:"headers_bar,omitempty"`
Logs Logs `toml:"logs,omitempty" json:"logs,omitempty"`
PopupDelete PopupDelete `toml:"popup_delete,omitempty" json:"popup_delete,omitempty"`
PopupError PopupError `toml:"popup_error,omitempty" json:"popup_error,omitempty"`
PopupHelp PopupHelp `toml:"popup_help,omitempty" json:"popup_help,omitempty"`
PopupInfo PopupInfo `toml:"popup_info,omitempty" json:"popup_info,omitempty"`
}
type Borders struct {
Selected string `toml:"selected,omitempty" json:"selected,omitempty"`
Unselected string `toml:"unselected,omitempty" json:"unselected,omitempty"`
}
type ChartCPU struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Border string `toml:"border,omitempty" json:"border,omitempty"`
Title string `toml:"title,omitempty" json:"title,omitempty"`
Max string `toml:"max,omitempty" json:"max,omitempty"`
Points string `toml:"points,omitempty" json:"points,omitempty"`
YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
}
type ChartMemory struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Border string `toml:"border,omitempty" json:"border,omitempty"`
Title string `toml:"title,omitempty" json:"title,omitempty"`
Max string `toml:"max,omitempty" json:"max,omitempty"`
Points string `toml:"points,omitempty" json:"points,omitempty"`
YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
}
type ChartBandwidth struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Border string `toml:"border,omitempty" json:"border,omitempty"`
MaxRX string `toml:"max_rx,omitempty" json:"max_rx,omitempty"`
MaxTX string `toml:"max_tx,omitempty" json:"max_tx,omitempty"`
PointsRX string `toml:"points_rx,omitempty" json:"points_rx,omitempty"`
PointsTX string `toml:"points_tx,omitempty" json:"points_tx,omitempty"`
TitleRX string `toml:"title_rx,omitempty" json:"title_rx,omitempty"`
TitleTX string `toml:"title_tx,omitempty" json:"title_tx,omitempty"`
YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
}
type ChartPorts struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Border string `toml:"border,omitempty" json:"border,omitempty"`
Title string `toml:"title,omitempty" json:"title,omitempty"`
Headings string `toml:"headings,omitempty" json:"headings,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
}
type Commands struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Pause string `toml:"pause,omitempty" json:"pause,omitempty"`
Restart string `toml:"restart,omitempty" json:"restart,omitempty"`
Stop string `toml:"stop,omitempty" json:"stop,omitempty"`
Delete string `toml:"delete,omitempty" json:"delete,omitempty"`
Resume string `toml:"resume,omitempty" json:"resume,omitempty"`
Start string `toml:"start,omitempty" json:"start,omitempty"`
}
type ContainerState struct {
Dead string `toml:"dead,omitempty" json:"dead,omitempty"`
Exited string `toml:"exited,omitempty" json:"exited,omitempty"`
Paused string `toml:"paused,omitempty" json:"paused,omitempty"`
Removing string `toml:"removing,omitempty" json:"removing,omitempty"`
Restarting string `toml:"restarting,omitempty" json:"restarting,omitempty"`
RunningHealthy string `toml:"running_healthy,omitempty" json:"running_healthy,omitempty"`
RunningUnhealthy string `toml:"running_unhealthy,omitempty" json:"running_unhealthy,omitempty"`
Unknown string `toml:"unknown,omitempty" json:"unknown,omitempty"`
}
type Containers struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Icon string `toml:"icon,omitempty" json:"icon,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
TextRX string `toml:"text_rx,omitempty" json:"text_rx,omitempty"`
TextTX string `toml:"text_tx,omitempty" json:"text_tx,omitempty"`
}
type LogSearch struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
ButtonText string `toml:"button_text,omitempty" json:"button_text,omitempty"`
Highlight string `toml:"highlight,omitempty" json:"highlight,omitempty"`
}
type Filter struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
SelectedFilterBackground string `toml:"selected_filter_background,omitempty" json:"selected_filter_background,omitempty"`
SelectedFilterText string `toml:"selected_filter_text,omitempty" json:"selected_filter_text,omitempty"`
Highlight string `toml:"highlight,omitempty" json:"highlight,omitempty"`
}
type HeadersBar struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
LoadingSpinner string `toml:"loading_spinner,omitempty" json:"loading_spinner,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
TextSelected string `toml:"text_selected,omitempty" json:"text_selected,omitempty"`
}
type Logs struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
}
type PopupDelete struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
TextHighlight string `toml:"text_highlight,omitempty" json:"text_highlight,omitempty"`
}
type PopupError struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
}
type PopupHelp struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
TextHighlight string `toml:"text_highlight,omitempty" json:"text_highlight,omitempty"`
}
type PopupInfo struct {
Background string `toml:"background,omitempty" json:"background,omitempty"`
Text string `toml:"text,omitempty" json:"text,omitempty"`
}
type Keymap struct {
Clear []string `toml:"clear,omitempty" json:"clear,omitempty"`
ClearSecondary []string `toml:"-" json:"-"`
DeleteConfirm []string `toml:"delete_confirm,omitempty" json:"delete_confirm,omitempty"`
DeleteConfirmSecondary []string `toml:"-" json:"-"`
DeleteDeny []string `toml:"delete_deny,omitempty" json:"delete_deny,omitempty"`
DeleteDenySecondary []string `toml:"-" json:"-"`
Exec []string `toml:"exec,omitempty" json:"exec,omitempty"`
ExecSecondary []string `toml:"-" json:"-"`
FilterMode []string `toml:"filter_mode,omitempty" json:"filter_mode,omitempty"`
FilterModeSecondary []string `toml:"-" json:"-"`
Inspect []string `toml:"inspect,omitempty" json:"inspect,omitempty"`
InspectSecondary []string `toml:"-" json:"-"`
ForceRedraw []string `toml:"force_redraw,omitempty" json:"force_redraw,omitempty"`
ForceRedrawSecondary []string `toml:"-" json:"-"`
ScrollBack []string `toml:"scroll_back,omitempty" json:"scroll_back,omitempty"`
ScrollBackSecondary []string `toml:"-" json:"-"`
ScrollForward []string `toml:"scroll_forward,omitempty" json:"scroll_forward,omitempty"`
ScrollForwardSecondary []string `toml:"-" json:"-"`
LogSearchMode []string `toml:"log_search_mode,omitempty" json:"log_search_mode,omitempty"`
LogSearchModeSecondary []string `toml:"-" json:"-"`
LogSectionHeightDecrease []string `toml:"log_section_height_decrease,omitempty" json:"log_section_height_decrease,omitempty"`
LogSectionHeightDecreaseSecondary []string `toml:"-" json:"-"`
LogSectionHeightIncrease []string `toml:"log_section_height_increase,omitempty" json:"log_section_height_increase,omitempty"`
LogSectionHeightIncreaseSecondary []string `toml:"-" json:"-"`
LogSectionToggle []string `toml:"log_section_toggle,omitempty" json:"log_section_toggle,omitempty"`
LogSectionToggleSecondary []string `toml:"-" json:"-"`
Quit []string `toml:"quit,omitempty" json:"quit,omitempty"`
QuitSecondary []string `toml:"-" json:"-"`
SaveLogs []string `toml:"save_logs,omitempty" json:"save_logs,omitempty"`
SaveLogsSecondary []string `toml:"-" json:"-"`
ScrollDown []string `toml:"scroll_down,omitempty" json:"scroll_down,omitempty"`
ScrollDownSecondary []string `toml:"-" json:"-"`
ScrollEnd []string `toml:"scroll_end,omitempty" json:"scroll_end,omitempty"`
ScrollEndSecondary []string `toml:"-" json:"-"`
ScrollStart []string `toml:"scroll_start,omitempty" json:"scroll_start,omitempty"`
ScrollStartSecondary []string `toml:"-" json:"-"`
ScrollUp []string `toml:"scroll_up,omitempty" json:"scroll_up,omitempty"`
ScrollUpSecondary []string `toml:"-" json:"-"`
SelectNextPanel []string `toml:"select_next_panel,omitempty" json:"select_next_panel,omitempty"`
SelectNextPanelSecondary []string `toml:"-" json:"-"`
SelectPreviousPanel []string `toml:"select_previous_panel,omitempty" json:"select_previous_panel,omitempty"`
SelectPreviousPanelSecondary []string `toml:"-" json:"-"`
SortByName []string `toml:"sort_by_name,omitempty" json:"sort_by_name,omitempty"`
SortByNameSecondary []string `toml:"-" json:"-"`
SortByState []string `toml:"sort_by_state,omitempty" json:"sort_by_state,omitempty"`
SortByStateSecondary []string `toml:"-" json:"-"`
SortByStatus []string `toml:"sort_by_status,omitempty" json:"sort_by_status,omitempty"`
SortByStatusSecondary []string `toml:"-" json:"-"`
SortByCPU []string `toml:"sort_by_cpu,omitempty" json:"sort_by_cpu,omitempty"`
SortByCPUSecondary []string `toml:"-" json:"-"`
SortByMemory []string `toml:"sort_by_memory,omitempty" json:"sort_by_memory,omitempty"`
SortByMemorySecondary []string `toml:"-" json:"-"`
SortByID []string `toml:"sort_by_id,omitempty" json:"sort_by_id,omitempty"`
SortByIDSecondary []string `toml:"-" json:"-"`
SortByImage []string `toml:"sort_by_image,omitempty" json:"sort_by_image,omitempty"`
SortByImageSecondary []string `toml:"-" json:"-"`
SortByRX []string `toml:"sort_by_rx,omitempty" json:"sort_by_rx,omitempty"`
SortByRXSecondary []string `toml:"-" json:"-"`
SortByTX []string `toml:"sort_by_tx,omitempty" json:"sort_by_tx,omitempty"`
SortByTXSecondary []string `toml:"-" json:"-"`
SortReset []string `toml:"sort_reset,omitempty" json:"sort_reset,omitempty"`
SortResetSecondary []string `toml:"-" json:"-"`
ToggleHelp []string `toml:"toggle_help,omitempty" json:"toggle_help,omitempty"`
ToggleHelpSecondary []string `toml:"-" json:"-"`
ToggleMouseCapture []string `toml:"toggle_mouse_capture,omitempty" json:"toggle_mouse_capture,omitempty"`
ToggleMouseCaptureSecondary []string `toml:"-" json:"-"`
LogSectionToggleAlt []string `toml:"-" json:"-"`
ScrollMany string `toml:"scroll_many,omitempty" json:"scroll_many,omitempty"`
}
type Config struct {
AppColors AppColors `toml:"colors,omitempty" json:"colors,omitempty"`
ColorLogs bool `toml:"color_logs,omitempty" json:"color_logs,omitempty"`
DirSave string `toml:"save_dir,omitempty" json:"save_dir,omitempty"`
DirConfig string `toml:"-" json:"-"`
DockerIntervalMs int `toml:"docker_interval,omitempty" json:"docker_interval,omitempty"`
GUI bool `toml:"gui,omitempty" json:"gui,omitempty"`
Host string `toml:"host,omitempty" json:"host,omitempty"`
InContainer bool `toml:"-" json:"-"`
Keymap Keymap `toml:"keymap,omitempty" json:"keymap,omitempty"`
LogSearchCaseSensitive bool `toml:"log_search_case_sensitive,omitempty" json:"log_search_case_sensitive,omitempty"`
RawLogs bool `toml:"raw_logs,omitempty" json:"raw_logs,omitempty"`
ShowLogs bool `toml:"show_logs,omitempty" json:"show_logs,omitempty"`
ShowSelf bool `toml:"show_self,omitempty" json:"show_self,omitempty"`
ShowStdErr bool `toml:"show_std_err,omitempty" json:"show_std_err,omitempty"`
ShowTimestamp bool `toml:"show_timestamp,omitempty" json:"show_timestamp,omitempty"`
TimestampFormat string `toml:"timestamp_format,omitempty" json:"timestamp_format,omitempty"`
Timezone string `toml:"timezone,omitempty" json:"timezone,omitempty"`
UseCLI bool `toml:"use_cli,omitempty" json:"use_cli,omitempty"`
}
func NewConfig() *Config {
return &Config{
DockerIntervalMs: 1000,
ShowLogs: true,
ColorLogs: false,
RawLogs: false,
ShowTimestamp: true,
TimestampFormat: "%Y-%m-%dT%H:%M:%S.%8f",
Timezone: "UTC",
UseCLI: false,
GUI: true,
LogSearchCaseSensitive: true,
ShowSelf: false,
ShowStdErr: true,
DirSave: "",
Host: "",
}
}
func LoadConfigPath(path string, cfg *Config) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
content := string(data)
if strings.HasSuffix(path, ".toml") {
_, err = toml.Decode(content, cfg)
if err != nil {
return err
}
} else if strings.HasSuffix(path, ".jsonc") || strings.HasSuffix(path, ".json") {
err = parseJSONC(content, cfg)
if err != nil {
return err
}
}
cfg.DirConfig = path
return nil
}
func LoadConfig(path string) (*Config, error) {
cfg := NewConfig()
err := LoadConfigPath(path, cfg)
if err != nil {
return nil, err
}
return cfg, nil
}
func parseJSONC(content string, cfg *Config) error {
var raw map[string]interface{}
commentsRemoved := removeJSONCComments(content)
err := json.Unmarshal([]byte(commentsRemoved), &raw)
if err != nil {
return err
}
if colors, ok := raw["colors"].(map[string]interface{}); ok {
cfg.AppColors = parseAppColors(colors)
}
if keymap, ok := raw["keymap"].(map[string]interface{}); ok {
cfg.Keymap = parseKeymap(keymap)
}
if val, ok := raw["color_logs"].(bool); ok {
cfg.ColorLogs = val
}
if val, ok := raw["docker_interval"].(float64); ok {
cfg.DockerIntervalMs = int(val)
}
if val, ok := raw["gui"].(bool); ok {
cfg.GUI = val
}
if val, ok := raw["host"].(string); ok {
cfg.Host = val
}
if val, ok := raw["log_search_case_sensitive"].(bool); ok {
cfg.LogSearchCaseSensitive = val
}
if val, ok := raw["raw_logs"].(bool); ok {
cfg.RawLogs = val
}
if val, ok := raw["save_dir"].(string); ok {
cfg.DirSave = val
}
if val, ok := raw["show_logs"].(bool); ok {
cfg.ShowLogs = val
}
if val, ok := raw["show_self"].(bool); ok {
cfg.ShowSelf = val
}
if val, ok := raw["show_std_err"].(bool); ok {
cfg.ShowStdErr = val
}
if val, ok := raw["show_timestamp"].(bool); ok {
cfg.ShowTimestamp = val
}
if val, ok := raw["timestamp_format"].(string); ok {
cfg.TimestampFormat = val
}
if val, ok := raw["timezone"].(string); ok {
cfg.Timezone = val
}
if val, ok := raw["use_cli"].(bool); ok {
cfg.UseCLI = val
}
return nil
}
func removeJSONCComments(content string) string {
lines := strings.Split(content, "\n")
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "*/") {
continue
}
if idx := strings.Index(trimmed, "//"); idx >= 0 {
result = append(result, strings.TrimSpace(line[:idx]))
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
func parseAppColors(colors map[string]interface{}) AppColors {
acc := AppColors{}
if borders, ok := colors["borders"].(map[string]interface{}); ok {
acc.Borders = Borders{
Selected: toString(borders["selected"]),
Unselected: toString(borders["unselected"]),
}
}
if chartCPU, ok := colors["chart_cpu"].(map[string]interface{}); ok {
acc.ChartCPU = ChartCPU{
Background: toString(chartCPU["background"]),
Border: toString(chartCPU["border"]),
Title: toString(chartCPU["title"]),
Max: toString(chartCPU["max"]),
Points: toString(chartCPU["points"]),
YAxis: toString(chartCPU["y_axis"]),
}
}
if chartMemory, ok := colors["chart_memory"].(map[string]interface{}); ok {
acc.ChartMemory = ChartMemory{
Background: toString(chartMemory["background"]),
Border: toString(chartMemory["border"]),
Title: toString(chartMemory["title"]),
Max: toString(chartMemory["max"]),
Points: toString(chartMemory["points"]),
YAxis: toString(chartMemory["y_axis"]),
}
}
if chartBandwidth, ok := colors["chart_bandwidth"].(map[string]interface{}); ok {
acc.ChartBandwidth = ChartBandwidth{
Background: toString(chartBandwidth["background"]),
Border: toString(chartBandwidth["border"]),
MaxRX: toString(chartBandwidth["max_rx"]),
MaxTX: toString(chartBandwidth["max_tx"]),
PointsRX: toString(chartBandwidth["points_rx"]),
PointsTX: toString(chartBandwidth["points_tx"]),
TitleRX: toString(chartBandwidth["title_rx"]),
TitleTX: toString(chartBandwidth["title_tx"]),
YAxis: toString(chartBandwidth["y_axis"]),
}
}
if chartPorts, ok := colors["chart_ports"].(map[string]interface{}); ok {
acc.ChartPorts = ChartPorts{
Background: toString(chartPorts["background"]),
Border: toString(chartPorts["border"]),
Title: toString(chartPorts["title"]),
Headings: toString(chartPorts["headings"]),
Text: toString(chartPorts["text"]),
}
}
if commands, ok := colors["commands"].(map[string]interface{}); ok {
acc.Commands = Commands{
Background: toString(commands["background"]),
Pause: toString(commands["pause"]),
Restart: toString(commands["restart"]),
Stop: toString(commands["stop"]),
Delete: toString(commands["delete"]),
Resume: toString(commands["resume"]),
Start: toString(commands["start"]),
}
}
if containerState, ok := colors["container_state"].(map[string]interface{}); ok {
acc.ContainerState = ContainerState{
Dead: toString(containerState["dead"]),
Exited: toString(containerState["exited"]),
Paused: toString(containerState["paused"]),
Removing: toString(containerState["removing"]),
Restarting: toString(containerState["restarting"]),
RunningHealthy: toString(containerState["running_healthy"]),
RunningUnhealthy: toString(containerState["running_unhealthy"]),
Unknown: toString(containerState["unknown"]),
}
}
if containers, ok := colors["containers"].(map[string]interface{}); ok {
acc.Containers = Containers{
Background: toString(containers["background"]),
Icon: toString(containers["icon"]),
Text: toString(containers["text"]),
TextRX: toString(containers["text_rx"]),
TextTX: toString(containers["text_tx"]),
}
}
if logSearch, ok := colors["log_search"].(map[string]interface{}); ok {
acc.LogSearch = LogSearch{
Background: toString(logSearch["background"]),
Text: toString(logSearch["text"]),
ButtonText: toString(logSearch["button_text"]),
Highlight: toString(logSearch["highlight"]),
}
}
if filter, ok := colors["filter"].(map[string]interface{}); ok {
acc.Filter = Filter{
Background: toString(filter["background"]),
Text: toString(filter["text"]),
SelectedFilterBackground: toString(filter["selected_filter_background"]),
SelectedFilterText: toString(filter["selected_filter_text"]),
Highlight: toString(filter["highlight"]),
}
}
if headersBar, ok := colors["headers_bar"].(map[string]interface{}); ok {
acc.HeadersBar = HeadersBar{
Background: toString(headersBar["background"]),
LoadingSpinner: toString(headersBar["loading_spinner"]),
Text: toString(headersBar["text"]),
TextSelected: toString(headersBar["text_selected"]),
}
}
if logs, ok := colors["logs"].(map[string]interface{}); ok {
acc.Logs = Logs{
Background: toString(logs["background"]),
Text: toString(logs["text"]),
}
}
if popupDelete, ok := colors["popup_delete"].(map[string]interface{}); ok {
acc.PopupDelete = PopupDelete{
Background: toString(popupDelete["background"]),
Text: toString(popupDelete["text"]),
TextHighlight: toString(popupDelete["text_highlight"]),
}
}
if popupError, ok := colors["popup_error"].(map[string]interface{}); ok {
acc.PopupError = PopupError{
Background: toString(popupError["background"]),
Text: toString(popupError["text"]),
}
}
if popupHelp, ok := colors["popup_help"].(map[string]interface{}); ok {
acc.PopupHelp = PopupHelp{
Background: toString(popupHelp["background"]),
Text: toString(popupHelp["text"]),
TextHighlight: toString(popupHelp["text_highlight"]),
}
}
if popupInfo, ok := colors["popup_info"].(map[string]interface{}); ok {
acc.PopupInfo = PopupInfo{
Background: toString(popupInfo["background"]),
Text: toString(popupInfo["text"]),
}
}
return acc
}
func parseKeymap(keymap map[string]interface{}) Keymap {
k := Keymap{}
applyKeymapEntry(&k.Clear, keymap, "clear")
applyKeymapEntry(&k.DeleteConfirm, keymap, "delete_confirm")
applyKeymapEntry(&k.DeleteDeny, keymap, "delete_deny")
applyKeymapEntry(&k.Exec, keymap, "exec")
applyKeymapEntry(&k.FilterMode, keymap, "filter_mode")
applyKeymapEntry(&k.Inspect, keymap, "inspect")
applyKeymapEntry(&k.ForceRedraw, keymap, "force_redraw")
applyKeymapEntry(&k.ScrollBack, keymap, "scroll_back")
applyKeymapEntry(&k.ScrollForward, keymap, "scroll_forward")
applyKeymapEntry(&k.LogSearchMode, keymap, "log_search_mode")
applyKeymapEntry(&k.LogSectionHeightDecrease, keymap, "log_section_height_decrease")
applyKeymapEntry(&k.LogSectionHeightIncrease, keymap, "log_section_height_increase")
applyKeymapEntry(&k.LogSectionToggle, keymap, "log_section_toggle")
applyKeymapEntry(&k.Quit, keymap, "quit")
applyKeymapEntry(&k.SaveLogs, keymap, "save_logs")
applyKeymapEntry(&k.ScrollDown, keymap, "scroll_down")
applyKeymapEntry(&k.ScrollEnd, keymap, "scroll_end")
applyKeymapEntry(&k.ScrollStart, keymap, "scroll_start")
applyKeymapEntry(&k.ScrollUp, keymap, "scroll_up")
applyKeymapEntry(&k.SelectNextPanel, keymap, "select_next_panel")
applyKeymapEntry(&k.SelectPreviousPanel, keymap, "select_previous_panel")
applyKeymapEntry(&k.SortByName, keymap, "sort_by_name")
applyKeymapEntry(&k.SortByState, keymap, "sort_by_state")
applyKeymapEntry(&k.SortByStatus, keymap, "sort_by_status")
applyKeymapEntry(&k.SortByCPU, keymap, "sort_by_cpu")
applyKeymapEntry(&k.SortByMemory, keymap, "sort_by_memory")
applyKeymapEntry(&k.SortByID, keymap, "sort_by_id")
applyKeymapEntry(&k.SortByImage, keymap, "sort_by_image")
applyKeymapEntry(&k.SortByRX, keymap, "sort_by_rx")
applyKeymapEntry(&k.SortByTX, keymap, "sort_by_tx")
applyKeymapEntry(&k.SortReset, keymap, "sort_reset")
applyKeymapEntry(&k.ToggleHelp, keymap, "toggle_help")
applyKeymapEntry(&k.ToggleMouseCapture, keymap, "toggle_mouse_capture")
if scrollMany, ok := keymap["scroll_many"].(string); ok {
k.ScrollMany = scrollMany
}
return k
}
func applyKeymapEntry(target *[]string, keymap map[string]interface{}, key string) {
if val, ok := keymap[key].([]interface{}); ok {
for _, v := range val {
if s, ok := v.(string); ok {
*target = append(*target, s)
}
}
}
}
func toString(val interface{}) string {
if s, ok := val.(string); ok {
return s
}
return ""
}
func (c *Config) SaveConfig() error {
path := c.DirConfig
if path == "" {
var err error
path, err = GetDefaultConfigPath()
if err != nil {
return err
}
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
if strings.HasSuffix(path, ".toml") {
return toml.NewEncoder(file).Encode(c)
} else if strings.HasSuffix(path, ".jsonc") || strings.HasSuffix(path, ".json") {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
_, err = file.Write(data)
return err
}
return nil
}
func GetDefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configDir := filepath.Join(home, ".config", "oxker")
return filepath.Join(configDir, "config.toml"), nil
}
func (c *Config) MergeCLIArgs(cliArgs *CLIArgs) {
if cliArgs == nil {
return
}
if cliArgs.DockerIntervalMs > 0 {
c.DockerIntervalMs = cliArgs.DockerIntervalMs
}
c.RawLogs = cliArgs.RawLogs
c.ColorLogs = cliArgs.ColorLogs
c.ShowTimestamp = !cliArgs.ShowTimestamp
c.UseCLI = cliArgs.UseCLI
c.GUI = !cliArgs.DebugMode
if cliArgs.Host != "" {
c.Host = cliArgs.Host
}
c.DirConfig = cliArgs.ConfigFile
}
type CLIArgs struct {
DockerIntervalMs int
RawLogs bool
ColorLogs bool
ShowTimestamp bool
ShowSelf bool
DebugMode bool
ConfigFile string
Host string
NoStdErr bool
SaveDir string
Timezone string
UseCLI bool
}
+112
View File
@@ -0,0 +1,112 @@
package docker
import (
"context"
"encoding/json"
"io"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
type Client struct {
cli *client.Client
ctx context.Context
cancel context.CancelFunc
}
func New(host ...string) (*Client, error) {
opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
if len(host) > 0 && host[0] != "" {
opts = append(opts, client.WithHost(host[0]))
}
cli, err := client.NewClientWithOpts(opts...)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
return &Client{cli: cli, ctx: ctx, cancel: cancel}, nil
}
// Cancel cancels all in-flight Docker API calls.
func (c *Client) Cancel() {
c.cancel()
}
func (c *Client) Ping() error {
_, err := c.cli.Ping(c.ctx)
return err
}
func (c *Client) ListContainers(all bool) ([]types.Container, error) {
opts := container.ListOptions{All: all}
return c.cli.ContainerList(c.ctx, opts)
}
func (c *Client) InspectContainer(id string) (types.ContainerJSON, error) {
return c.cli.ContainerInspect(c.ctx, id)
}
func (c *Client) StartContainer(id string) error {
return c.cli.ContainerStart(c.ctx, id, container.StartOptions{})
}
func (c *Client) StopContainer(id string) error {
return c.cli.ContainerStop(c.ctx, id, container.StopOptions{})
}
func (c *Client) RestartContainer(id string) error {
return c.cli.ContainerRestart(c.ctx, id, container.StopOptions{})
}
func (c *Client) DeleteContainer(id string, force bool) error {
return c.cli.ContainerRemove(c.ctx, id, container.RemoveOptions{Force: force})
}
func (c *Client) PauseContainer(id string) error {
return c.cli.ContainerPause(c.ctx, id)
}
func (c *Client) UnpauseContainer(id string) error {
return c.cli.ContainerUnpause(c.ctx, id)
}
func (c *Client) Logs(id string, options container.LogsOptions) (io.ReadCloser, error) {
return c.cli.ContainerLogs(c.ctx, id, options)
}
// ContainerStats gets a single stats snapshot for a container.
// Uses stream=false (NOT one-shot) so Docker fills in precpu_stats for accurate CPU delta.
func (c *Client) ContainerStats(id string) (*container.StatsResponse, error) {
resp, err := c.cli.ContainerStats(c.ctx, id, false)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var stats container.StatsResponse
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, err
}
return &stats, nil
}
func (c *Client) ContainerExec(id string, cmd []string, tty bool) (types.HijackedResponse, error) {
execConfig := container.ExecOptions{
Cmd: cmd,
Tty: tty,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
}
exec, err := c.cli.ContainerExecCreate(c.ctx, id, execConfig)
if err != nil {
return types.HijackedResponse{}, err
}
return c.cli.ContainerExecAttach(c.ctx, exec.ID, container.ExecStartOptions{})
}
func (c *Client) Close() error {
return c.cli.Close()
}
+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"time"
)
type RateLimiter struct {
lastTime time.Time
minInterval time.Duration
}
func NewRateLimiter(interval time.Duration) *RateLimiter {
return &RateLimiter{
minInterval: interval,
}
}
func (r *RateLimiter) Allow() bool {
now := time.Now()
if now.Sub(r.lastTime) >= r.minInterval {
r.lastTime = now
return true
}
return false
}
func (r *RateLimiter) Reset() {
r.lastTime = time.Time{}
}
+31
View File
@@ -0,0 +1,31 @@
package utils
type ScrollDirection int
const (
ScrollUp ScrollDirection = iota
ScrollDown
ScrollPageUp
ScrollPageDown
ScrollHome
ScrollEnd
)
func (sd ScrollDirection) String() string {
switch sd {
case ScrollUp:
return "up"
case ScrollDown:
return "down"
case ScrollPageUp:
return "pageup"
case ScrollPageDown:
return "pagedown"
case ScrollHome:
return "home"
case ScrollEnd:
return "end"
default:
return "unknown"
}
}
+24
View File
@@ -0,0 +1,24 @@
package utils
type ContainerStats struct {
ID string
CPU float64
Memory uint64
MemoryLimit uint64
RX uint64
TX uint64
}
func ContainerStatsFromDocker(stats interface{}, id string) *ContainerStats {
var cpu float64
var mem, memLimit, rx, tx uint64
return &ContainerStats{
ID: id,
CPU: cpu,
Memory: mem,
MemoryLimit: memLimit,
RX: rx,
TX: tx,
}
}
+26
View File
@@ -0,0 +1,26 @@
package utils
import (
"strings"
"github.com/olekukonko/tablewriter"
)
func FormatTable(data [][]string) string {
var sb strings.Builder
table := tablewriter.NewWriter(&sb)
table.SetHeader([]string{})
table.SetBorder(false)
table.SetColMinWidth(0, 10)
table.SetColMinWidth(1, 20)
table.SetColMinWidth(2, 25)
table.SetColMinWidth(3, 15)
table.SetColMinWidth(4, 20)
for _, row := range data {
table.Append(row)
}
table.Render()
return sb.String()
}
Submodule
+1
Submodule source/oxker added at e020eb157c
File diff suppressed because it is too large Load Diff
-2571
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
use crate::app_data::DockerCommand;
use std::fmt;
/// app errors to set in global state
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum AppError {
DockerCommand(DockerCommand),
DockerExec,
DockerLogs,
DockerConnect,
IO(String),
MouseCapture(bool),
Parse(String),
Terminal,
}
/// Convert errors into strings to display
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::DockerCommand(s) => write!(f, "Unable to {s} container"),
Self::DockerExec => write!(f, "Unable to exec into container"),
Self::DockerLogs => write!(f, "Unable to save logs"),
Self::DockerConnect => write!(f, "Unable to access docker daemon"),
Self::IO(msg) => write!(f, "IO error with: {msg}"),
Self::MouseCapture(x) => {
let reason = if *x { "en" } else { "dis" };
write!(f, "Unable to {reason}able mouse capture")
}
Self::Parse(msg) => write!(f, "Parsing error: {msg}"),
Self::Terminal => write!(f, "Unable to fully render to terminal"),
}
}
}
-551
View File
@@ -1,551 +0,0 @@
use ratatui::style::Color;
static COLOR_RX: Color = Color::Rgb(255, 233, 193);
static COLOR_TX: Color = Color::Rgb(205, 140, 140);
/// The macro accepts a list of struct names with key names
/// Returns a struct where every key name is an Option<String>, with the correct derived attributes
macro_rules! optional_config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
struct $struct_name {
$(
$key_name: Option<String>,
)*
}
)*
};
}
/// The macro accepts a list of struct names with key names
macro_rules! config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub struct $struct_name {
$(
pub $key_name: Color,
)*
}
)*
};
}
impl AppColors {
fn map_color(color_str: Option<&str>, setter: &mut Color) {
color_str.map(|i| i.parse::<Color>().map(|i| *setter = i));
}
}
impl From<Option<ConfigColors>> for AppColors {
#[allow(clippy::too_many_lines)]
fn from(value: Option<ConfigColors>) -> Self {
let mut app_colors = Self::new();
if let Some(config_colors) = value {
// Heading bar
if let Some(hb) = config_colors.headers_bar {
Self::map_color(
hb.background.as_deref(),
&mut app_colors.headers_bar.background,
);
Self::map_color(
hb.loading_spinner.as_deref(),
&mut app_colors.headers_bar.loading_spinner,
);
Self::map_color(hb.text.as_deref(), &mut app_colors.headers_bar.text);
Self::map_color(
hb.text_selected.as_deref(),
&mut app_colors.headers_bar.text_selected,
);
}
// Selectable panel borders
if let Some(b) = config_colors.borders {
Self::map_color(b.selected.as_deref(), &mut app_colors.borders.selected);
Self::map_color(b.unselected.as_deref(), &mut app_colors.borders.unselected);
}
// Error Popup
if let Some(ep) = config_colors.popup_error {
Self::map_color(
ep.background.as_deref(),
&mut app_colors.popup_error.background,
);
Self::map_color(ep.text.as_deref(), &mut app_colors.popup_error.text);
}
// Filter panel
if let Some(fc) = config_colors.filter {
Self::map_color(fc.background.as_deref(), &mut app_colors.filter.background);
Self::map_color(fc.highlight.as_deref(), &mut app_colors.filter.highlight);
Self::map_color(
fc.selected_filter_background.as_deref(),
&mut app_colors.filter.selected_filter_background,
);
Self::map_color(
fc.selected_filter_text.as_deref(),
&mut app_colors.filter.selected_filter_text,
);
Self::map_color(fc.text.as_deref(), &mut app_colors.filter.text);
}
// Log search
if let Some(ls) = config_colors.log_search {
Self::map_color(
ls.background.as_deref(),
&mut app_colors.log_search.background,
);
Self::map_color(
ls.highlight.as_deref(),
&mut app_colors.log_search.highlight,
);
Self::map_color(
ls.button_text.as_deref(),
&mut app_colors.log_search.button_text,
);
Self::map_color(ls.text.as_deref(), &mut app_colors.log_search.text);
}
// Help Popup
if let Some(hp) = config_colors.popup_help {
Self::map_color(
hp.background.as_deref(),
&mut app_colors.popup_help.background,
);
Self::map_color(hp.text.as_deref(), &mut app_colors.popup_help.text);
Self::map_color(
hp.text_highlight.as_deref(),
&mut app_colors.popup_help.text_highlight,
);
}
// Info Popup
if let Some(ip) = config_colors.popup_info {
Self::map_color(
ip.background.as_deref(),
&mut app_colors.popup_info.background,
);
Self::map_color(ip.text.as_deref(), &mut app_colors.popup_info.text);
}
// Delete Popup
if let Some(dp) = config_colors.popup_delete {
Self::map_color(
dp.background.as_deref(),
&mut app_colors.popup_delete.background,
);
Self::map_color(dp.text.as_deref(), &mut app_colors.popup_delete.text);
Self::map_color(
dp.text_highlight.as_deref(),
&mut app_colors.popup_delete.text_highlight,
);
}
// Chart Cpu
if let Some(cc) = config_colors.chart_cpu {
Self::map_color(
cc.background.as_deref(),
&mut app_colors.chart_cpu.background,
);
Self::map_color(cc.border.as_deref(), &mut app_colors.chart_cpu.border);
Self::map_color(cc.max.as_deref(), &mut app_colors.chart_cpu.max);
Self::map_color(cc.points.as_deref(), &mut app_colors.chart_cpu.points);
Self::map_color(cc.title.as_deref(), &mut app_colors.chart_cpu.title);
Self::map_color(cc.y_axis.as_deref(), &mut app_colors.chart_cpu.y_axis);
}
// Chart Memory
if let Some(cm) = config_colors.chart_memory {
Self::map_color(
cm.background.as_deref(),
&mut app_colors.chart_memory.background,
);
Self::map_color(cm.border.as_deref(), &mut app_colors.chart_memory.border);
Self::map_color(cm.max.as_deref(), &mut app_colors.chart_memory.max);
Self::map_color(cm.points.as_deref(), &mut app_colors.chart_memory.points);
Self::map_color(cm.title.as_deref(), &mut app_colors.chart_memory.title);
Self::map_color(cm.y_axis.as_deref(), &mut app_colors.chart_memory.y_axis);
}
// Chart ports
if let Some(cp) = config_colors.chart_ports {
Self::map_color(
cp.background.as_deref(),
&mut app_colors.chart_ports.background,
);
Self::map_color(cp.border.as_deref(), &mut app_colors.chart_ports.border);
Self::map_color(cp.headings.as_deref(), &mut app_colors.chart_ports.headings);
Self::map_color(cp.text.as_deref(), &mut app_colors.chart_ports.text);
Self::map_color(cp.title.as_deref(), &mut app_colors.chart_ports.title);
}
// Containers
if let Some(c) = config_colors.containers {
Self::map_color(
c.background.as_deref(),
&mut app_colors.containers.background,
);
Self::map_color(c.icon.as_deref(), &mut app_colors.containers.icon);
Self::map_color(c.text.as_deref(), &mut app_colors.containers.text);
Self::map_color(c.text_rx.as_deref(), &mut app_colors.containers.text_rx);
Self::map_color(c.text_tx.as_deref(), &mut app_colors.containers.text_tx);
}
// Commands
if let Some(cc) = config_colors.commands {
Self::map_color(
cc.background.as_deref(),
&mut app_colors.commands.background,
);
Self::map_color(cc.pause.as_deref(), &mut app_colors.commands.pause);
Self::map_color(cc.restart.as_deref(), &mut app_colors.commands.restart);
Self::map_color(cc.stop.as_deref(), &mut app_colors.commands.stop);
Self::map_color(cc.delete.as_deref(), &mut app_colors.commands.start);
Self::map_color(cc.resume.as_deref(), &mut app_colors.commands.resume);
Self::map_color(cc.start.as_deref(), &mut app_colors.commands.start);
}
// Logs panel
if let Some(cl) = config_colors.logs {
Self::map_color(cl.background.as_deref(), &mut app_colors.logs.background);
Self::map_color(cl.text.as_deref(), &mut app_colors.logs.text);
}
// Container State
if let Some(cs) = config_colors.container_state {
Self::map_color(cs.dead.as_deref(), &mut app_colors.container_state.dead);
Self::map_color(cs.exited.as_deref(), &mut app_colors.container_state.exited);
Self::map_color(cs.paused.as_deref(), &mut app_colors.container_state.paused);
Self::map_color(
cs.removing.as_deref(),
&mut app_colors.container_state.removing,
);
Self::map_color(
cs.restarting.as_deref(),
&mut app_colors.container_state.restarting,
);
Self::map_color(
cs.running_healthy.as_deref(),
&mut app_colors.container_state.running_healthy,
);
Self::map_color(
cs.running_unhealthy.as_deref(),
&mut app_colors.container_state.running_unhealthy,
);
Self::map_color(
cs.unknown.as_deref(),
&mut app_colors.container_state.unknown,
);
}
}
app_colors
}
}
const ORANGE: Color = Color::Rgb(255, 178, 36);
optional_config_struct!(
ConfigBackgroundText, background, text;
ConfigBackgroundTextHighlight, background, text, text_highlight;
ConfigBorders, selected, unselected;
ConfigChartBandwidth, background, border, max_rx, max_tx, title_tx, title_rx, points_rx, points_tx, y_axis;
ConfigChartCpu, background, border, order, title, max, points,y_axis;
ConfigChartMemory, background, border, title, max, points, y_axis;
ConfigChartPorts, background, border, title, headings, text;
ConfigCommands, background, pause, restart, stop, delete, resume, start;
ConfigContainers, background, icon, text, text_rx, text_tx;
ConfigContainerState, background, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown;
ConfigFilter, background, text, selected_filter_background, selected_filter_text, highlight;
ConfigLogSearch, background, text, button_text, highlight;
ConfigHeadersBar, background, loading_spinner, text, text_selected;
ConfigLogs, background, text
);
config_struct!(
Borders, selected, unselected;
ChartCpu, background, border, title, max, points, y_axis;
ChartMemory, background, border, title, max, points, y_axis;
ChartBandwidth, background, border, max_rx, max_tx, title_rx, title_tx, points_rx, points_tx, y_axis;
ChartPorts, background, border, title, headings, text;
Commands, background, pause, restart, stop, delete, resume, start;
Containers, background, icon, text, text_rx, text_tx;
ContainerState, dead, exited, paused, removing, restarting, running_healthy, running_unhealthy, unknown;
Filter, background, text, selected_filter_background, selected_filter_text, highlight;
LogSearch, background, text, button_text, highlight;
HeadersBar, background, text_selected, loading_spinner, text;
Logs, background, text;
PopupDelete, background, text, text_highlight;
PopupError, background, text;
PopupHelp, background, text, text_highlight;
PopupInfo, background, text
);
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct ConfigColors {
borders: Option<ConfigBorders>,
chart_cpu: Option<ConfigChartCpu>,
chart_memory: Option<ConfigChartMemory>,
chart_bandwidth: Option<ConfigChartBandwidth>,
chart_ports: Option<ConfigChartPorts>,
commands: Option<ConfigCommands>,
container_state: Option<ConfigContainerState>,
containers: Option<ConfigContainers>,
filter: Option<ConfigFilter>,
log_search: Option<ConfigLogSearch>,
headers_bar: Option<ConfigHeadersBar>,
logs: Option<ConfigLogs>,
popup_delete: Option<ConfigBackgroundTextHighlight>,
popup_error: Option<ConfigBackgroundText>,
popup_help: Option<ConfigBackgroundTextHighlight>,
popup_info: Option<ConfigBackgroundText>,
}
/// Default colours for the header bar
impl HeadersBar {
const fn new() -> Self {
Self {
background: Color::Magenta,
loading_spinner: Color::White,
text: Color::Black,
text_selected: Color::Gray,
}
}
}
/// Default colours for the borders
impl Borders {
const fn new() -> Self {
Self {
selected: Color::LightCyan,
unselected: Color::Gray,
}
}
}
/// Default colours for the delete popup
impl Commands {
const fn new() -> Self {
Self {
background: Color::Reset,
pause: Color::Yellow,
restart: Color::Magenta,
stop: Color::Red,
delete: Color::Gray,
resume: Color::Blue,
start: Color::Green,
}
}
}
/// Default colours for the Bandwidth chart
impl ChartBandwidth {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
max_rx: COLOR_RX,
title_rx: COLOR_RX,
title_tx: COLOR_TX,
max_tx: COLOR_TX,
points_rx: COLOR_RX,
points_tx: COLOR_TX,
y_axis: Color::White,
}
}
}
/// Default colours for the CPU chart
impl ChartCpu {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
max: ORANGE,
points: Color::Magenta,
y_axis: Color::White,
}
}
}
/// Default colours for the help popup
impl ChartMemory {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
max: ORANGE,
points: Color::Cyan,
y_axis: Color::White,
}
}
}
/// Default colours for the help popup
impl ChartPorts {
const fn new() -> Self {
Self {
background: Color::Reset,
border: Color::White,
title: Color::Green,
headings: Color::Yellow,
text: Color::White,
}
}
}
/// Default colours for the help popup
impl Containers {
const fn new() -> Self {
Self {
background: Color::Reset,
icon: Color::White,
text: Color::Blue,
text_rx: COLOR_RX,
text_tx: COLOR_TX,
}
}
}
/// Default colours for the help popup
impl ContainerState {
const fn new() -> Self {
Self {
paused: Color::Yellow,
removing: Color::LightRed,
restarting: Color::LightGreen,
running_healthy: Color::Green,
running_unhealthy: ORANGE,
dead: Color::Red,
exited: Color::Red,
unknown: Color::Red,
}
}
}
/// Default colours for the filter panel
impl Filter {
const fn new() -> Self {
Self {
background: Color::Reset,
highlight: Color::Magenta,
selected_filter_background: Color::Gray,
selected_filter_text: Color::Black,
text: Color::Gray,
}
}
}
/// Default colours for the log search
impl LogSearch {
const fn new() -> Self {
Self {
background: Color::Reset,
highlight: Color::Magenta,
button_text: Color::Black,
text: Color::Gray,
}
}
}
/// Default colours for the logs panel, only applied if color_logs is false
impl Logs {
const fn new() -> Self {
Self {
background: Color::Reset,
text: Color::Reset,
}
}
}
/// Default colours for the Error popup
impl PopupError {
const fn new() -> Self {
Self {
background: Color::Red,
text: Color::White,
}
}
}
/// Default colours for the info popup
impl PopupInfo {
const fn new() -> Self {
Self {
background: Color::Blue,
text: Color::White,
}
}
}
/// Default colours for the help popup
impl PopupHelp {
const fn new() -> Self {
Self {
background: Color::Magenta,
text: Color::Black,
text_highlight: Color::White,
}
}
}
/// Default colours for the delete popup
impl PopupDelete {
const fn new() -> Self {
Self {
background: Color::White,
text: Color::Black,
text_highlight: Color::Red,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
pub struct AppColors {
pub borders: Borders,
pub chart_cpu: ChartCpu,
pub chart_memory: ChartMemory,
pub chart_bandwidth: ChartBandwidth,
pub chart_ports: ChartPorts,
pub commands: Commands,
pub container_state: ContainerState,
pub containers: Containers,
pub log_search: LogSearch,
pub filter: Filter,
pub headers_bar: HeadersBar,
pub logs: Logs,
pub popup_delete: PopupDelete,
pub popup_error: PopupError,
pub popup_help: PopupHelp,
pub popup_info: PopupInfo,
}
impl AppColors {
pub const fn new() -> Self {
Self {
borders: Borders::new(),
chart_cpu: ChartCpu::new(),
chart_memory: ChartMemory::new(),
chart_bandwidth: ChartBandwidth::new(),
chart_ports: ChartPorts::new(),
commands: Commands::new(),
container_state: ContainerState::new(),
containers: Containers::new(),
log_search: LogSearch::new(),
filter: Filter::new(),
headers_bar: HeadersBar::new(),
logs: Logs::new(),
popup_delete: PopupDelete::new(),
popup_error: PopupError::new(),
popup_help: PopupHelp::new(),
popup_info: PopupInfo::new(),
}
}
}
-321
View File
@@ -1,321 +0,0 @@
# oxker config file
# oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
# Every key is optional, with defaults that oxker will choose if missing or invalid
# The `--config-file` cli argument can be used to load configuration files from any readable location
# Docker update interval in ms, minimum effectively 1000
docker_interval = 1000
# Attempt to colorize the logs, conflicts with "raw"
color_logs = false
# Show raw logs, default is to remove ansi formatting, conflicts with "color"
raw_logs = false
# Show self (the oxker container) when running as a docker container
show_self = false
# Show std_err in logs
show_std_err = true
# Show a timestamp for every log entry
show_timestamp = true
# Don't draw gui - for debugging - mostly pointless
gui = true
# Docker host location. Will take priority over a DOCKER_HOST env.
# host = "/var/run/docker.sock"
# Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC
timezone = "Etc/UTC"
# Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.012345678Z
# *Should* accept any valid strftime string up to 32 chars, see https://strftime.org/
timestamp_format = "%Y-%m-%dT%H:%M:%S.%8f"
# Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
# save_dir = "$HOME"
# Force use of docker cli when execing into containers, honestly mostly pointless
use_cli = false
# Show the logs section - this can be changed during operation with the log_section_toggle key
show_logs = true
# Use case-sensitive matching for logs
log_search_case_sensitive = true
#################
# Custom Keymap #
#################
# Available keys are;
# 1) a-z and A-Z
# 2) 0-9
# WARNING if using the \ key, it needs to be escaped, e.g. log_section_toggle = ["\\"]
# 3) / \ , . # ' [ ] ; = -
# 3) F1-F12
# 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
# Each definition can have two keys associated with it
# WARNING "scroll_many" only accepts control, alt, shift, with no secondary option
# If any key clashes are found, oxker will revert to it's default keymap
[keymap]
# Clear any popup boxes, filter panel, or help panel
clear = ["c", "esc"]
# Cancel delete - clear also works here
delete_deny = ["n"]
# Confirm Delete
delete_confirm = ["y"]
# Exec into the selected container
exec = ["e"]
# Enter filter mode
filter_mode = ["/", "F1"]
# Enter log search mode
log_search_mode = ["#"]
# Quit at anytime
quit = ["q"]
# Save logs of selected container to file on disk
save_logs = ["s"]
# scroll down a list by one item
scroll_down = ["down", "j"]
# scroll down to the end of a list
scroll_end = ["end"]
# Modifier to scroll by 10 lines instead of one, used in conjunction with scroll_up/scroll_down
scroll_many = ["control"]
# scroll up to the start of a list
scroll_start = ["home"]
# scroll up a list by one item
scroll_up = ["up", "k"]
# Horizontal scroll of the logs
scroll_forward = ["right"]
scroll_back = ["left"]
# Select next panel
select_next_panel = ["tab"]
# Select previous panel
select_previous_panel = ["backtab"]
# Sort the containers based on specific column
sort_by_name = ["1"]
sort_by_state = ["2"]
sort_by_status = ["3"]
sort_by_cpu = ["4"]
sort_by_memory = ["5"]
sort_by_id = ["6"]
sort_by_image = ["7"]
sort_by_rx = ["8"]
sort_by_tx = ["9"]
# Reset the sorted containers
sort_reset = ["0"]
# Toggle the help panel
toggle_help = ["h"]
# Toggle mouse capture
toggle_mouse_capture = ["m"]
# Reduce the height of the logs list section
log_section_height_decrease = ["-"]
log_section_height_increase = ["+"]
# Toggle visibility of the log section
log_section_toggle = ["\\"]
# Toggle to inspect container screen
inspect = ["i"]
# Force a complete clear & redraw of the screen
force_redraw = ["f"]
#################
# Custom Colors #
#################
# Colors be listed as either;
# 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
# 2) Hex values: '#FF0000', case insensitive
# 3) 'reset' for transparency
# Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
# The single line bar at the uppermost of the display. Contains name/state/status headings etc
[colors.headers_bar]
# Background color of the entire line
background = "magenta"
# Animated loading icon at the start of the bar
loading_spinner = "white"
# Text color
text = "black"
# Text color of a selected header
text_selected = "gray"
# The borders around the selectable panels - Containers, Commands, Logs
[colors.borders]
# Border when selected
selected = "lightcyan"
# Border when not selected
unselected = "grey"
# The containers sections, in the future more color customization options should be made available in this section
[colors.containers]
# The icon use to illustrate which container is currently selected - at the moment the TUI library, ratatui, doesn't seem allow changing the color of the highlight symbol
icon = "white"
# Background color of panel
background = "reset"
# At the moment, this will only change the color of the name, id, and image columns
text = "blue"
# Text color of the RX column
text_rx = "#FFE9C1"
# Text color of the TX column
text_tx = "#CD8C8C"
# The logs panel, will only be applied if color_logs is false
[colors.logs]
# Background color of panel
background = "reset"
# text color
text = "reset"
# Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
[colors.container_state]
dead = "red"
exited = "red"
paused = "yellow"
removing = "lightred"
restarting = "lightgreen"
running_healthy = "green"
running_unhealthy = "#FFB224"
unknown = "red"
# The filter panel
[colors.filter]
# Background color of panel
background = "reset"
# color of text
text = "gray"
# background color of the selected filter by item (Name/Image/Status/All)
selected_filter_background = "gray"
# text color of the selected filter by item (Name/Image/Status/All)
selected_filter_text = "black"
# Highlighted text color
highlight = "magenta"
# The log search panel
[colors.log_search]
# Background color of panel
background = "reset"
# color of text
text = "gray"
# text color of the buttons text
button_text = "black"
# Highlighted text color
highlight = "magenta"
# The color the of Docker commands available for each container
[colors.commands]
# Background color of panel
background = "reset"
pause = "yellow"
restart = "magenta"
stop = "red"
delete = "gray"
resume = "blue"
start = "green"
# The cpu chart
[colors.chart_cpu]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped colors not yet customizable - or could just use state color?
title = "green"
# Maximum CPU percentage - again paused & stopped colors not yet customizable
max = "#FFB224"
# Points on the chart - again paused & stopped colors not yet customizable
points = "magenta"
# The charts y-axis
y_axis = "white"
# The memory chart
[colors.chart_memory]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title = "green"
# Maximum memory use - again paused & stopped will use colors.container_state
max = "#FFB224"
# Points on the chart - again paused & stopped will use colors.container_state
points = "cyan"
# The charts y-axis
y_axis = "white"
# The bandwidth chart
[colors.chart_bandwidth]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Maximum RX value - again paused & stopped colors not yet customizable
max_rx = "#FFE9C1"
# Maximum TX value - again paused & stopped colors not yet customizable
max_tx = "#CD8C8C"
# RX points on the chart - again paused & stopped colors not yet customizable
points_rx = "#FFE9C1"
# TX points on the chart - again paused & stopped colors not yet customizable
points_tx = "#CD8C8C"
# TX title color
title_rx = "#FFE9C1"
# RX title color
title_tx = "#CD8C8C"
# The charts y-axis
y_axis = "white"
# The ports chart
[colors.chart_ports]
# Background color of panel
background = "reset"
# Border color
border = "white"
# Chart title - only whilst container is running, paused & stopped will use colors.container_state
title = "green"
# Private/Public/IP headings
headings = "yellow"
# Ports & IP listing text
text = "white"
# The help popup
[colors.popup_help]
# Background color
background = "magenta"
# Text color
text = "black"
# Highlighted text color
text_highlight = "white"
# The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
[colors.popup_info]
# Background color
background = "blue"
# Text color
text = "white"
# The delete popup - used to display a confirmation warning when about to delete a container
[colors.popup_delete]
# Background color
background = "white"
# Text color
text = "black"
# Highlighted text color
text_highlight = "red"
# The error popup - hopefully you'll never have to see this
[colors.popup_error]
# Background color
background = "red"
# Text color
text = "white"
-514
View File
@@ -1,514 +0,0 @@
use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyModifiers};
/// The macro accepts a list of struct names with key names
/// Returns a struct where every key name is an Option<String>, with the correct derived attributes
macro_rules! optional_config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct $struct_name {
$(
$key_name: Option<Vec<String>>,
)*
pub scroll_many: Option<Vec<String>>,
}
)*
};
}
/// The macro accepts a list of struct names with key names
/// Similar to the optional_config_struct macro as above, but returns struct where every key name is Color
macro_rules! config_struct {
($($struct_name:ident, $($key_name:ident),*);*) => {
$(
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct $struct_name {
$(
pub $key_name: (KeyCode, Option<KeyCode>),
)*
pub scroll_many: KeyModifiers,
}
)*
};
}
optional_config_struct!(
ConfigKeymap,
clear,
delete_confirm,
delete_deny,
exec,
filter_mode,
force_redraw,
inspect,
scroll_back,
scroll_forward,
log_search_mode,
log_section_height_decrease,
log_section_height_increase,
log_section_toggle,
quit,
save_logs,
scroll_down,
scroll_end,
scroll_start,
scroll_up,
select_next_panel,
select_previous_panel,
sort_by_cpu,
sort_by_id,
sort_by_image,
sort_by_memory,
sort_by_name,
sort_by_rx,
sort_by_state,
sort_by_status,
sort_by_tx,
sort_reset,
toggle_help,
toggle_mouse_capture
);
config_struct!(
Keymap,
clear,
delete_confirm,
delete_deny,
exec,
filter_mode,
inspect,
force_redraw,
scroll_back,
scroll_forward,
log_search_mode,
log_section_height_decrease,
log_section_height_increase,
log_section_toggle,
quit,
save_logs,
scroll_down,
scroll_end,
scroll_start,
scroll_up,
select_next_panel,
select_previous_panel,
sort_by_cpu,
sort_by_id,
sort_by_image,
sort_by_memory,
sort_by_name,
sort_by_rx,
sort_by_state,
sort_by_status,
sort_by_tx,
sort_reset,
toggle_help,
toggle_mouse_capture
);
impl Keymap {
pub const fn new() -> Self {
Self {
clear: (KeyCode::Char('c'), Some(KeyCode::Esc)),
delete_confirm: (KeyCode::Char('y'), None),
delete_deny: (KeyCode::Char('n'), None),
exec: (KeyCode::Char('e'), None),
inspect: (KeyCode::Char('i'), None),
filter_mode: (KeyCode::Char('/'), Some(KeyCode::F(1))),
force_redraw: (KeyCode::Char('f'), None),
scroll_back: (KeyCode::Left, None),
scroll_forward: (KeyCode::Right, None),
log_search_mode: (KeyCode::Char('#'), None),
log_section_height_decrease: (KeyCode::Char('-'), None),
log_section_height_increase: (KeyCode::Char('='), None),
log_section_toggle: (KeyCode::Char('\\'), None),
quit: (KeyCode::Char('q'), None),
save_logs: (KeyCode::Char('s'), None),
scroll_down: (KeyCode::Down, Some(KeyCode::Char('j'))),
scroll_end: (KeyCode::End, None),
scroll_many: KeyModifiers::CONTROL,
scroll_start: (KeyCode::Home, None),
scroll_up: (KeyCode::Up, Some(KeyCode::Char('k'))),
select_next_panel: (KeyCode::Tab, None),
select_previous_panel: (KeyCode::BackTab, None),
sort_by_cpu: (KeyCode::Char('4'), None),
sort_by_id: (KeyCode::Char('6'), None),
sort_by_image: (KeyCode::Char('7'), None),
sort_by_memory: (KeyCode::Char('5'), None),
sort_by_name: (KeyCode::Char('1'), None),
sort_by_rx: (KeyCode::Char('8'), None),
sort_by_state: (KeyCode::Char('2'), None),
sort_by_status: (KeyCode::Char('3'), None),
sort_by_tx: (KeyCode::Char('9'), None),
sort_reset: (KeyCode::Char('0'), None),
toggle_help: (KeyCode::Char('h'), None),
toggle_mouse_capture: (KeyCode::Char('m'), None),
}
}
}
impl From<Option<ConfigKeymap>> for Keymap {
/// Probably a better way to do this, but for now it works
fn from(value: Option<ConfigKeymap>) -> Self {
let mut keymap = Self::new();
let mut clash = HashSet::new();
let mut counter = 0;
let mut update_keymap =
|vec_str: Option<Vec<String>>,
keymap_field: &mut (KeyCode, Option<KeyCode>),
keymap_clash: &mut HashSet<KeyCode>| {
if let Some(vec_str) = vec_str
&& let Some(vec_keycode) = Self::try_parse_keycode(&vec_str)
{
if let Some(first) = vec_keycode.first() {
keymap_clash.insert(*first);
counter += 1;
keymap_field.0 = *first;
}
if let Some(second) = vec_keycode.get(1) {
keymap_clash.insert(*second);
counter += 1;
keymap_field.1 = Some(*second);
} else {
keymap_field.1 = None;
}
}
};
if let Some(ck) = value {
update_keymap(ck.clear, &mut keymap.clear, &mut clash);
update_keymap(ck.delete_deny, &mut keymap.delete_deny, &mut clash);
update_keymap(ck.delete_confirm, &mut keymap.delete_confirm, &mut clash);
update_keymap(
ck.log_section_height_decrease,
&mut keymap.log_section_height_decrease,
&mut clash,
);
update_keymap(
ck.log_section_height_increase,
&mut keymap.log_section_height_increase,
&mut clash,
);
update_keymap(
ck.log_section_toggle,
&mut keymap.log_section_toggle,
&mut clash,
);
update_keymap(ck.exec, &mut keymap.exec, &mut clash);
update_keymap(ck.filter_mode, &mut keymap.filter_mode, &mut clash);
update_keymap(ck.force_redraw, &mut keymap.force_redraw, &mut clash);
update_keymap(ck.quit, &mut keymap.quit, &mut clash);
update_keymap(ck.save_logs, &mut keymap.save_logs, &mut clash);
update_keymap(ck.scroll_down, &mut keymap.scroll_down, &mut clash);
update_keymap(ck.scroll_end, &mut keymap.scroll_end, &mut clash);
update_keymap(ck.scroll_start, &mut keymap.scroll_start, &mut clash);
update_keymap(ck.scroll_up, &mut keymap.scroll_up, &mut clash);
update_keymap(ck.log_search_mode, &mut keymap.log_search_mode, &mut clash);
update_keymap(ck.scroll_forward, &mut keymap.scroll_forward, &mut clash);
update_keymap(ck.scroll_back, &mut keymap.scroll_back, &mut clash);
update_keymap(
ck.select_next_panel,
&mut keymap.select_next_panel,
&mut clash,
);
update_keymap(
ck.select_previous_panel,
&mut keymap.select_previous_panel,
&mut clash,
);
update_keymap(ck.sort_by_name, &mut keymap.sort_by_name, &mut clash);
update_keymap(ck.sort_by_state, &mut keymap.sort_by_state, &mut clash);
update_keymap(ck.sort_by_status, &mut keymap.sort_by_status, &mut clash);
update_keymap(ck.sort_by_cpu, &mut keymap.sort_by_cpu, &mut clash);
update_keymap(ck.sort_by_memory, &mut keymap.sort_by_memory, &mut clash);
update_keymap(ck.sort_by_id, &mut keymap.sort_by_id, &mut clash);
update_keymap(ck.sort_by_image, &mut keymap.sort_by_image, &mut clash);
update_keymap(ck.sort_by_rx, &mut keymap.sort_by_rx, &mut clash);
update_keymap(ck.sort_by_tx, &mut keymap.sort_by_tx, &mut clash);
update_keymap(ck.sort_reset, &mut keymap.sort_reset, &mut clash);
update_keymap(ck.toggle_help, &mut keymap.toggle_help, &mut clash);
update_keymap(
ck.toggle_mouse_capture,
&mut keymap.toggle_mouse_capture,
&mut clash,
);
// TODO need to check for clashes when using additional modifiers
if let Some(scroll_many) = Self::try_parse_modifier(ck.scroll_many) {
keymap.scroll_many = scroll_many;
}
}
// A very basic clash check, every key has been inserted into a hashset, and a counter has been increased
// if the counter and hashet length don't match, then there's a clash, and we just return the default keymap
if counter == clash.len() {
keymap
} else {
Self::new()
}
}
}
impl Keymap {
// Allowable key modifiers are only `shift`, `control`, `alt`
fn try_parse_modifier(input: Option<Vec<String>>) -> Option<KeyModifiers> {
input.and_then(|input| {
input
.first()
.and_then(|input| match input.to_lowercase().trim() {
"control" => Some(KeyModifiers::CONTROL),
"alt" => Some(KeyModifiers::ALT),
"shift" => Some(KeyModifiers::SHIFT),
_ => None,
})
})
}
/// Try to parse a &[String] into a Vec of keycodes, at most the output will have 2 entries
/// This might fail on MacOS due to Backspace and Delete working in a different manner as to how they work on Linux & Windows
/// I think that on MacOS `Del` becomes `Fwd Del`, and `Backspace` becomes `Delete`
fn try_parse_keycode(input: &[String]) -> Option<Vec<KeyCode>> {
let mut output = vec![];
for key in input.iter().take(2) {
if key.chars().count() == 1 {
if let Some(first_char) = key.chars().next()
&& let Some(first_char) = match first_char {
x if x.is_ascii_alphabetic() || x.is_ascii_digit() => Some(first_char),
'/' | '\\' | ',' | '.' | '#' | '\'' | '[' | ']' | ';' | '=' | '-' => {
Some(first_char)
}
_ => None,
}
{
output.push(KeyCode::Char(first_char));
}
} else {
let keycode = match key.to_lowercase().as_str() {
"f1" => Some(KeyCode::F(1)),
"f2" => Some(KeyCode::F(2)),
"f3" => Some(KeyCode::F(3)),
"f4" => Some(KeyCode::F(4)),
"f5" => Some(KeyCode::F(5)),
"f6" => Some(KeyCode::F(6)),
"f7" => Some(KeyCode::F(7)),
"f8" => Some(KeyCode::F(8)),
"f9" => Some(KeyCode::F(9)),
"f10" => Some(KeyCode::F(10)),
"f11" => Some(KeyCode::F(11)),
"f12" => Some(KeyCode::F(12)),
// Might fail on MacOS, see note above
"backspace" => Some(KeyCode::Backspace),
"backtab" => Some(KeyCode::BackTab),
// Might fail on MacOS, see note above
"delete" => Some(KeyCode::Delete),
"down" => Some(KeyCode::Down),
"end" => Some(KeyCode::End),
"esc" => Some(KeyCode::Esc),
"home" => Some(KeyCode::Home),
"insert" => Some(KeyCode::Insert),
"left" => Some(KeyCode::Left),
"pagedown" => Some(KeyCode::PageDown),
"pageup" => Some(KeyCode::PageUp),
"right" => Some(KeyCode::Right),
"tab" => Some(KeyCode::Tab),
"up" => Some(KeyCode::Up),
_ => None,
};
if let Some(a) = keycode {
output.push(a);
}
}
}
if output.is_empty() {
None
} else {
// Remove any duplicates for a single definition
if output.first() == output.get(1) {
output.pop();
}
Some(output)
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::{KeyCode, KeyModifiers};
use crate::config::keymap_parser::ConfigKeymap;
use super::Keymap;
#[test]
/// Only allow two definitions to be parsed
fn test_return_max_two() {
let result = Keymap::try_parse_keycode(&["a".to_owned(), "b".to_owned(), "c".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('a'), KeyCode::Char('b')]));
let result = Keymap::try_parse_keycode(&["0".to_owned(), "1".to_owned(), "2".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('0'), KeyCode::Char('1')]));
let result =
Keymap::try_parse_keycode(&["esc".to_owned(), "tab".to_owned(), "backtab".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Esc, KeyCode::Tab]));
}
#[test]
/// If a single definition has two identical entries, just return a single entry
fn test_duplicate_definition() {
let result = Keymap::try_parse_keycode(&["c".to_owned(), "c".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('c')]));
let result = Keymap::try_parse_keycode(&["0".to_owned(), "0".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Char('0')]));
let result = Keymap::try_parse_keycode(&["esc".to_owned(), "esc".to_owned()]);
assert_eq!(result, Some(vec![KeyCode::Esc]));
}
#[test]
/// Return None is invalid key definition is provided
fn test_invalid_key() {
let result = Keymap::try_parse_keycode(&["(".to_owned(), "*".to_owned()]);
assert!(result.is_none());
let result = Keymap::try_parse_keycode(&["enter".to_owned(), "shift".to_owned()]);
assert!(result.is_none());
let result = Keymap::try_parse_keycode(&["ö".to_owned(), "ä".to_owned()]);
assert!(result.is_none());
}
#[test]
/// If any key definitions clash, just return the default keymap
fn test_clash_returns_default() {
let input = ConfigKeymap {
clear: Some(vec!["s".to_owned()]),
delete_deny: Some(vec!["s".to_owned()]),
delete_confirm: None,
exec: None,
filter_mode: None,
force_redraw: None,
inspect: None,
scroll_back: None,
log_search_mode: None,
scroll_forward: None,
log_section_height_decrease: None,
log_section_height_increase: None,
log_section_toggle: None,
quit: None,
save_logs: None,
scroll_down: None,
scroll_end: None,
scroll_start: None,
scroll_many: None,
scroll_up: None,
select_next_panel: None,
select_previous_panel: None,
sort_by_cpu: None,
sort_by_id: None,
sort_by_image: None,
sort_by_memory: None,
sort_by_name: None,
sort_by_rx: None,
sort_by_state: None,
sort_by_status: None,
sort_by_tx: None,
sort_reset: None,
toggle_help: None,
toggle_mouse_capture: None,
};
let result = Keymap::from(Some(input));
assert_eq!(result, Keymap::new());
}
#[test]
/// Custom keymap definition creation
fn test_valid_custom_keymap() {
let gen_v = |a: (&str, &str)| Some(vec![a.0.to_owned(), a.1.to_owned()]);
let input = ConfigKeymap {
clear: gen_v(("a", "b")),
delete_confirm: gen_v(("c", "d")),
delete_deny: gen_v(("e", "f")),
exec: gen_v(("g", "h")),
filter_mode: gen_v(("i", "j")),
force_redraw: gen_v(("k", "l")),
inspect: gen_v(("m", "n")),
scroll_back: gen_v(("s", "t")),
scroll_forward: gen_v(("q", "r")),
log_search_mode: gen_v(("1", "2")),
log_section_height_decrease: gen_v(("m", "n")),
log_section_height_increase: gen_v(("o", "p")),
log_section_toggle: gen_v(("u", "v")),
quit: gen_v(("w", "x")),
save_logs: gen_v(("y", "z")),
scroll_down: gen_v(("3", "4")),
scroll_end: gen_v(("5", "6")),
scroll_many: Some(vec!["alt".to_owned()]),
scroll_start: gen_v(("7", "8")),
scroll_up: gen_v(("F1", "F2")),
select_next_panel: gen_v(("F3", "F4")),
select_previous_panel: gen_v(("F5", "F6")),
sort_by_cpu: gen_v(("F7", "F8")),
sort_by_id: gen_v(("F9", "F10")),
sort_by_image: gen_v(("F11", "F12")),
sort_by_memory: gen_v(("HOME", "END")),
sort_by_name: gen_v(("UP", "DOWN")),
sort_by_rx: gen_v(("LEFT", "RIGHT")),
sort_by_state: gen_v(("[", "]")),
sort_by_status: gen_v(("INSERTt", "TAB")),
sort_by_tx: gen_v(("PAGEDOWN", "PAGEUP")),
sort_reset: gen_v((",", ".")),
toggle_help: gen_v(("-", "=")),
toggle_mouse_capture: gen_v(("\\", "/")),
};
let result = Keymap::from(Some(input));
let expected = Keymap {
clear: (KeyCode::Char('a'), Some(KeyCode::Char('b'))),
delete_confirm: (KeyCode::Char('c'), Some(KeyCode::Char('d'))),
delete_deny: (KeyCode::Char('e'), Some(KeyCode::Char('f'))),
exec: (KeyCode::Char('g'), Some(KeyCode::Char('h'))),
filter_mode: (KeyCode::Char('i'), Some(KeyCode::Char('j'))),
force_redraw: (KeyCode::Char('k'), Some(KeyCode::Char('l'))),
inspect: (KeyCode::Char('i'), None),
scroll_back: (KeyCode::Char('s'), Some(KeyCode::Char('t'))),
scroll_forward: (KeyCode::Char('q'), Some(KeyCode::Char('r'))),
log_search_mode: (KeyCode::Char('1'), Some(KeyCode::Char('2'))),
log_section_height_decrease: (KeyCode::Char('m'), Some(KeyCode::Char('n'))),
log_section_height_increase: (KeyCode::Char('o'), Some(KeyCode::Char('p'))),
log_section_toggle: (KeyCode::Char('u'), Some(KeyCode::Char('v'))),
quit: (KeyCode::Char('w'), Some(KeyCode::Char('x'))),
save_logs: (KeyCode::Char('y'), Some(KeyCode::Char('z'))),
scroll_down: (KeyCode::Char('3'), Some(KeyCode::Char('4'))),
scroll_end: (KeyCode::Char('5'), Some(KeyCode::Char('6'))),
scroll_many: KeyModifiers::ALT,
scroll_start: (KeyCode::Char('7'), Some(KeyCode::Char('8'))),
scroll_up: (KeyCode::F(1), Some(KeyCode::F(2))),
select_next_panel: (KeyCode::F(3), Some(KeyCode::F(4))),
select_previous_panel: (KeyCode::F(5), Some(KeyCode::F(6))),
sort_by_cpu: (KeyCode::F(7), Some(KeyCode::F(8))),
sort_by_id: (KeyCode::F(9), Some(KeyCode::F(10))),
sort_by_image: (KeyCode::F(11), Some(KeyCode::F(12))),
sort_by_memory: (KeyCode::Home, Some(KeyCode::End)),
sort_by_name: (KeyCode::Up, Some(KeyCode::Down)),
sort_by_rx: (KeyCode::Left, Some(KeyCode::Right)),
sort_by_state: (KeyCode::Char('['), Some(KeyCode::Char(']'))),
sort_by_status: (KeyCode::Tab, None),
sort_by_tx: (KeyCode::PageDown, Some(KeyCode::PageUp)),
sort_reset: (KeyCode::Char(','), Some(KeyCode::Char('.'))),
toggle_help: (KeyCode::Char('-'), Some(KeyCode::Char('='))),
toggle_mouse_capture: (KeyCode::Char('\\'), Some(KeyCode::Char('/'))),
};
assert_eq!(expected, result);
}
}
-285
View File
@@ -1,285 +0,0 @@
use std::path::PathBuf;
use clap::Parser;
use jiff::tz::TimeZone;
use parse_args::Args;
use parse_config_file::ConfigFile;
mod color_parser;
mod keymap_parser;
use crate::{ENV_KEY, ENV_VALUE};
pub use {color_parser::AppColors, keymap_parser::Keymap};
mod parse_args;
mod parse_config_file;
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct Config {
pub app_colors: AppColors,
pub color_logs: bool,
pub docker_interval_ms: u32,
pub gui: bool,
pub host: Option<String>,
pub in_container: bool,
pub keymap: Keymap,
pub log_search_case_sensitive: bool,
pub raw_logs: bool,
pub dir_config: Option<PathBuf>,
pub dir_save: Option<PathBuf>,
pub show_logs: bool,
pub show_self: bool,
pub show_std_err: bool,
pub show_timestamp: bool,
pub timestamp_format: String,
pub timezone: Option<TimeZone>,
pub use_cli: bool,
}
impl From<&Args> for Config {
fn from(args: &Args) -> Self {
Self {
app_colors: AppColors::new(),
color_logs: args.color,
docker_interval_ms: args.docker_interval,
gui: !args.gui,
host: args.host.clone(),
in_container: Self::check_if_in_container(),
keymap: Keymap::new(),
log_search_case_sensitive: true,
raw_logs: args.raw,
dir_save: Self::try_get_logs_dir(args.save_dir.as_ref()),
dir_config: args.config_file.as_ref().map(|i| PathBuf::from(&i)),
show_logs: true,
show_self: !args.show_self,
show_std_err: !args.no_std_err,
show_timestamp: !args.timestamp,
timestamp_format: Self::parse_timestamp_format(None),
timezone: Self::parse_timezone(args.timezone.clone()),
use_cli: args.use_cli,
}
}
}
impl From<(ConfigFile, Option<PathBuf>)> for Config {
fn from((config_file, dir): (ConfigFile, Option<PathBuf>)) -> Self {
Self {
app_colors: AppColors::from(config_file.colors),
color_logs: config_file.color_logs.unwrap_or(false),
docker_interval_ms: config_file.docker_interval.unwrap_or(1000),
dir_config: dir,
gui: config_file.gui.unwrap_or(true),
host: config_file.host,
in_container: Self::check_if_in_container(),
keymap: Keymap::from(config_file.keymap),
log_search_case_sensitive: config_file.log_search_case_sensitive.unwrap_or(true),
raw_logs: config_file.raw_logs.unwrap_or(false),
dir_save: Self::try_get_logs_dir(config_file.save_dir.as_ref()),
show_logs: config_file.show_logs.unwrap_or(true),
show_self: config_file.show_self.unwrap_or(false),
show_std_err: config_file.show_std_err.unwrap_or(true),
show_timestamp: config_file.show_timestamp.unwrap_or(true),
timestamp_format: Self::parse_timestamp_format(config_file.timestamp_format),
timezone: Self::parse_timezone(config_file.timezone),
use_cli: config_file.use_cli.unwrap_or(false),
}
}
}
impl Config {
/// A basic timestampt format parser, will only take 32 chars, and checks if the parsed timestamp isn't identical to the given formatter
fn parse_timestamp_format(input: Option<String>) -> String {
let default = || "%Y-%m-%dT%H:%M:%S.%8f".to_owned();
input.map_or_else(default, |input| {
if input.chars().count() >= 32
|| jiff::Timestamp::now().strftime(&input).to_string() == input
{
default()
} else {
input
}
})
}
/// Attempt to parse a timezone into a jiff::tz::TimeZone
/// Also return a format to display the timesampt in
fn parse_timezone(input: Option<String>) -> Option<TimeZone> {
let timezone_str = input?;
let Ok(tz) = jiff::tz::TimeZone::get(&timezone_str) else {
return None;
};
let current_ts = jiff::Timestamp::now();
let offset = tz.to_offset(current_ts);
if jiff::tz::TimeZone::UTC.to_offset(current_ts) == offset {
None
} else {
Some(tz)
}
}
/// Check if oxker is running inside of a container
fn check_if_in_container() -> bool {
std::env::var(ENV_KEY).is_ok_and(|i| i == ENV_VALUE)
}
/// If a cli_arg is provided, create a pathbuf from it, else try to get home_dir automatically
fn try_get_logs_dir(dir: Option<&String>) -> Option<PathBuf> {
dir.as_ref()
.map_or_else(Self::try_get_home_dir, |home_dir| {
Some(std::path::Path::new(&home_dir).to_owned())
})
}
/// Try to get the home dir of the current user
fn try_get_home_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|base_dirs| base_dirs.home_dir().to_owned())
}
/// Combine config from CLI into config file, the cli take priority
/// and also make sure color_logs and raw_logs can't clash
fn merge_args(mut self, config_from_cli: Self) -> Self {
let default_args = Args::default();
if config_from_cli.color_logs != default_args.color {
self.color_logs = config_from_cli.color_logs;
self.raw_logs = !self.color_logs;
}
if config_from_cli.raw_logs != default_args.raw {
self.raw_logs = config_from_cli.raw_logs;
self.color_logs = !self.raw_logs;
}
if config_from_cli.gui != default_args.gui {
self.gui = config_from_cli.gui;
}
if config_from_cli.docker_interval_ms != default_args.docker_interval {
self.docker_interval_ms = config_from_cli.docker_interval_ms;
}
if config_from_cli.docker_interval_ms < 1000 {
self.docker_interval_ms = default_args.docker_interval;
}
if config_from_cli.raw_logs != default_args.raw {
self.raw_logs = config_from_cli.raw_logs;
}
if config_from_cli.show_self != default_args.show_self {
self.show_self = config_from_cli.show_self;
}
if config_from_cli.show_std_err != default_args.no_std_err {
self.show_std_err = config_from_cli.show_std_err;
}
if config_from_cli.show_timestamp != default_args.timestamp {
self.show_timestamp = config_from_cli.show_timestamp;
}
if config_from_cli.use_cli != default_args.use_cli {
self.use_cli = config_from_cli.use_cli;
}
if let Some(host) = config_from_cli.host {
self.host = Some(host);
}
if let Some(x) = config_from_cli.dir_save {
self.dir_save = Some(x);
}
if let Some(tz) = config_from_cli.timezone {
self.timezone = Some(tz);
}
if self.color_logs && self.raw_logs {
self.raw_logs = false;
}
self
}
/// Generate a new config file
/// First check cli args,
/// then if a config file location is given check then
/// Else check the default location
/// else just return the default config + the cli args
/// cli args will take precedence over config settings
pub fn new() -> Self {
let in_container = Self::check_if_in_container();
let args = Args::parse();
let config_from_cli = Self::from(&args);
if let Some(dir_config_file) = &args.config_file
&& let Some(config_file) =
parse_config_file::ConfigFile::try_parse_from_file(dir_config_file)
{
return Self::from((config_file, Some(PathBuf::from(dir_config_file))))
.merge_args(config_from_cli);
}
if let Some((config_file, dir)) = parse_config_file::ConfigFile::try_parse(in_container) {
return Self::from((config_file, Some(dir))).merge_args(config_from_cli);
}
config_from_cli
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use jiff::tz::TimeZone;
/// Test the basic timestamp_format parsing/checker function
#[test]
fn test_config_parse_timestamp_format() {
let default = "%Y-%m-%dT%H:%M:%S.%8f";
let result = super::Config::parse_timestamp_format(None);
assert_eq!(result, default);
let result = super::Config::parse_timestamp_format(Some(String::new()));
assert_eq!(result, default);
let result = super::Config::parse_timestamp_format(Some(" ".to_owned()));
assert_eq!(result, default);
let result = super::Config::parse_timestamp_format(Some(" ".to_owned()));
assert_eq!(result, default);
let result =
super::Config::parse_timestamp_format(Some("not a valid formatter".to_owned()));
assert_eq!(result, default);
let result = super::Config::parse_timestamp_format(Some(
"%A, %B %d, %Y %I:%M %p %A, %B %d, %Y %I:%M %p".to_owned(),
));
assert_eq!(result, default);
let input = "%Y-%m-%d %H:%M:%S";
let result = super::Config::parse_timestamp_format(Some(input.to_owned()));
assert_eq!(result, input);
let input = "%Y-%j";
let result = super::Config::parse_timestamp_format(Some(input.to_owned()));
assert_eq!(result, input);
}
#[test]
/// Test various timezones get parsed correctly
fn test_config_parse_timezone() {
// Timezone with no offset just return None
for i in [None, Some("UTC".to_owned())] {
assert!(super::Config::parse_timezone(i).is_none());
}
let expected = Some(TimeZone::get("Asia/Tokyo").unwrap());
// string case ignored
for i in ["ASIA/TOKYO", "asia/tokyo", "aSiA/tOkYo"] {
let result = super::Config::parse_timezone(Some(i.to_owned()));
assert!(result.is_some());
assert_eq!(result, expected);
}
}
}
-74
View File
@@ -1,74 +0,0 @@
use clap::Parser;
use serde::Deserialize;
#[derive(Parser, Debug, Clone, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
#[command(version, about)]
pub struct Args {
/// Docker update interval in ms, minimum effectively 1000
#[clap(short = 'd', value_name = "ms", default_value_t = 1000)]
pub docker_interval: u32,
/// Remove timestamps from Docker logs
#[clap(short = 't')]
pub timestamp: bool,
/// Attempt to colorize the logs, conflicts with "-r"
#[clap(short = 'c', conflicts_with = "raw")]
pub color: bool,
/// Show raw logs, default is to remove ansi formatting, conflicts with "-c"
#[clap(short = 'r', conflicts_with = "color")]
pub raw: bool,
/// Show self when running as a docker container
#[clap(short = 's')]
pub show_self: bool,
/// Don't draw gui - for debugging - mostly pointless
#[clap(short = 'g')]
pub gui: bool,
/// Docker host, defaults to `/var/run/docker.sock`
#[clap(long, short = None)]
pub host: Option<String>,
/// Do not include stderr output in logs
#[clap(long = "no-stderr")]
pub no_std_err: bool,
/// Display the container logs timestamp with a given timezone, default is UTC
#[clap(long="timezone", short = None)]
pub timezone: Option<String>,
/// Directory for saving exported logs, defaults to `$HOME`
#[clap(long="save-dir", short = None)]
pub save_dir: Option<String>,
/// Path to a config file, readable as TOML, JSONC, or JSON
#[clap(long="config-file", short = None)]
pub config_file: Option<String>,
/// Force use of docker cli when execing into containers
#[clap(long="use-cli", short = None)]
pub use_cli: bool,
}
impl Default for Args {
fn default() -> Self {
Self {
docker_interval: 1000,
timestamp: true,
color: false,
raw: false,
show_self: false,
gui: true,
host: None,
no_std_err: true,
timezone: None,
save_dir: None,
config_file: None,
use_cli: false,
}
}
}
-259
View File
@@ -1,259 +0,0 @@
use std::{
io::{Read, Write},
path::PathBuf,
};
use serde::Deserialize;
use crate::app_error::AppError;
use super::{color_parser::ConfigColors, keymap_parser::ConfigKeymap};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigFileFormat {
Toml,
Jsonc,
Json,
JsoncAsJson,
}
impl TryFrom<&PathBuf> for ConfigFileFormat {
type Error = AppError;
/// Only allow toml, json, or jsonc files
fn try_from(value: &PathBuf) -> Result<Self, AppError> {
let err = || AppError::IO(format!("Can't parse give config file: {}", value.display()));
let Some(ext) = value.extension() else {
return Err(err());
};
let Some(ext) = ext.to_str() else {
return Err(err());
};
match ext {
"toml" => Ok(Self::Toml),
"json" => Ok(Self::Json),
"jsonc" => Ok(Self::Jsonc),
_ => Err(err()),
}
}
}
impl ConfigFileFormat {
/// Get the local config directory, to be used by default config parser
fn get_config_dir(in_container: bool) -> Option<PathBuf> {
if in_container {
Some(PathBuf::from("/"))
} else {
directories::BaseDirs::new()
.map(|base_dirs| base_dirs.config_local_dir().join(env!("CARGO_PKG_NAME")))
}
}
/// Return the default filename + path for a given fileformat
fn get_default_path_name(self, in_container: bool) -> PathBuf {
let suffix = match self {
Self::Json | Self::JsoncAsJson => "config.json",
Self::Jsonc => "config.jsonc",
Self::Toml => "config.toml",
};
Self::get_config_dir(in_container).map_or_else(|| PathBuf::from(suffix), |i| i.join(suffix))
}
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
pub struct ConfigFile {
pub color_logs: Option<bool>,
pub colors: Option<ConfigColors>,
pub docker_interval: Option<u32>,
pub gui: Option<bool>,
pub host: Option<String>,
pub keymap: Option<ConfigKeymap>,
pub log_search_case_sensitive: Option<bool>,
pub raw_logs: Option<bool>,
pub save_dir: Option<String>,
pub show_logs: Option<bool>,
pub show_self: Option<bool>,
pub show_std_err: Option<bool>,
pub show_timestamp: Option<bool>,
pub timestamp_format: Option<String>,
pub timezone: Option<String>,
pub use_cli: Option<bool>,
}
impl ConfigFile {
/// Attempt to create a config.toml file, will attempt to recursively create the directories as well
fn create_config_file(in_container: bool) -> Result<(), AppError> {
if in_container {
return Ok(());
}
let config_dir = ConfigFileFormat::get_config_dir(in_container)
.ok_or_else(|| AppError::IO("config_dir".to_owned()))?;
let file_name = config_dir.join("config.toml");
if !std::fs::exists(&file_name).map_err(|i| AppError::IO(i.to_string()))? {
if !std::fs::exists(&config_dir).map_err(|i| AppError::IO(i.to_string()))? {
std::fs::DirBuilder::new()
.recursive(true)
.create(&config_dir)
.map_err(|i| AppError::IO(i.to_string()))?;
}
let mut file =
std::fs::File::create_new(&file_name).map_err(|i| AppError::IO(i.to_string()))?;
file.write_all(include_bytes!("./config.toml"))
.map_err(|i| AppError::IO(i.to_string()))?;
file.flush().map_err(|i| AppError::IO(i.to_string()))?;
}
Ok(())
}
/// parse a given &str (read from the configfile) into Self
fn parse(file_format: ConfigFileFormat, input: &str) -> Result<Self, AppError> {
match file_format {
ConfigFileFormat::Json => {
serde_json::from_str::<Self>(input).map_err(|i| AppError::Parse(i.to_string()))
}
ConfigFileFormat::Jsonc | ConfigFileFormat::JsoncAsJson => {
serde_jsonc::from_str::<Self>(input).map_err(|i| AppError::Parse(i.to_string()))
}
ConfigFileFormat::Toml => {
toml::from_str::<Self>(input).map_err(|i| AppError::Parse(i.message().to_owned()))
}
}
}
/// Read the config file path to string, then attempt to parse
fn parse_config_file(file_format: ConfigFileFormat, path: &PathBuf) -> Result<Self, AppError> {
let mut file = std::fs::File::open(path).map_err(|_| {
AppError::IO(
path.to_str()
.map_or_else(String::new, std::borrow::ToOwned::to_owned),
)
})?;
let mut input = String::new();
file.read_to_string(&mut input)
.map_err(|i| AppError::IO(i.to_string()))?;
Self::parse(file_format, &input)
}
/// Try to parse the config file when the path is user supplied via cliargs
pub fn try_parse_from_file(path: &str) -> Option<Self> {
let path = PathBuf::from(path);
let Ok(file_format) = ConfigFileFormat::try_from(&path) else {
return None;
};
Self::parse_config_file(file_format, &path).ok()
}
/// Parse a config file using default config_file location
/// This is executed first, then the CLI args are read, and if they contain a "--config-file" entry, then Self::try_parse_from_file() is executed
pub fn try_parse(in_container: bool) -> Option<(Self, PathBuf)> {
let mut output = None;
for file_format in [
ConfigFileFormat::Toml,
ConfigFileFormat::Jsonc,
ConfigFileFormat::JsoncAsJson,
ConfigFileFormat::Json,
] {
let path = file_format.get_default_path_name(in_container);
if let Ok(config_file) = Self::parse_config_file(file_format, &path) {
output = Some((config_file, path));
break;
}
}
if output.is_none() {
Self::create_config_file(in_container).ok();
}
output
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::config::{AppColors, Keymap};
use super::ConfigFile;
#[test]
/// ./config.toml parses fine - as this is used to write a file on disk, it's vital that this is always valid
fn test_parse_config_toml_valid() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileFormat::Toml, example_toml);
assert!(result.is_ok());
}
#[test]
/// make sure config.toml matches the default keymap
fn test_parse_config_keymap_toml() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileFormat::Toml, example_toml).unwrap();
assert!(result.keymap.is_some());
assert_eq!(Keymap::from(result.keymap), Keymap::new());
}
#[test]
/// make sure example.config.jsonc matches the default keymap
fn test_parse_config_keymap_jsonc() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result = ConfigFile::parse(super::ConfigFileFormat::Jsonc, example_jsonc).unwrap();
assert!(result.keymap.is_some());
assert_eq!(Keymap::from(result.keymap), Keymap::new());
}
#[test]
/// All configs parsed and are equal
fn test_parse_config_keymap_all() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result_jsonc =
ConfigFile::parse(super::ConfigFileFormat::Jsonc, example_jsonc).unwrap();
assert!(result_jsonc.keymap.is_some());
let result_jsonc = result_jsonc.keymap.unwrap();
let example_toml = include_str!("./config.toml");
let result_toml = ConfigFile::parse(super::ConfigFileFormat::Toml, example_toml).unwrap();
assert!(result_toml.keymap.is_some());
let result_toml = result_toml.keymap.unwrap();
assert_eq!(Keymap::from(Some(result_toml.clone())), Keymap::new());
assert_eq!(result_toml, result_jsonc);
}
#[test]
/// make sure config.toml matches the default app colors
fn test_parse_config_colors_toml() {
let example_toml = include_str!("./config.toml");
let result = ConfigFile::parse(super::ConfigFileFormat::Toml, example_toml).unwrap();
assert!(result.colors.is_some());
assert_eq!(AppColors::from(result.colors), AppColors::new());
}
#[test]
/// make sure config.toml matches the default app colors
fn test_parse_config_colors_jsonc() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result = ConfigFile::parse(super::ConfigFileFormat::Jsonc, example_jsonc).unwrap();
assert!(result.colors.is_some());
assert_eq!(AppColors::from(result.colors), AppColors::new());
}
#[test]
/// All configs parsed and are equal
fn test_parse_config_colors_all() {
let example_jsonc = include_str!("../../example_config/example.config.jsonc");
let result_jsonc =
ConfigFile::parse(super::ConfigFileFormat::Jsonc, example_jsonc).unwrap();
assert!(result_jsonc.colors.is_some());
let result_jsonc = result_jsonc.colors.unwrap();
let example_toml = include_str!("./config.toml");
let result_toml = ConfigFile::parse(super::ConfigFileFormat::Toml, example_toml).unwrap();
assert!(result_toml.colors.is_some());
let result_toml = result_toml.colors.unwrap();
assert_eq!(AppColors::from(Some(result_toml.clone())), AppColors::new());
assert_eq!(result_toml, result_jsonc);
}
}
-14
View File
@@ -1,14 +0,0 @@
use std::sync::Arc;
use crate::app_data::{ContainerId, DockerCommand};
use bollard::Docker;
use tokio::sync::oneshot::Sender;
#[derive(Debug)]
pub enum DockerMessage {
ConfirmDelete(ContainerId),
Control((DockerCommand, ContainerId)),
Exec(Sender<Arc<Docker>>),
Inspect(ContainerId),
Update,
}
-665
View File
@@ -1,665 +0,0 @@
use bollard::{
Docker,
query_parameters::{
InspectContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions,
RestartContainerOptions, StartContainerOptions, StatsOptions, StopContainerOptions,
},
secret::ContainerStatsResponse,
service::ContainerSummary,
};
use futures_util::StreamExt;
use parking_lot::Mutex;
use std::{
collections::HashSet,
sync::{Arc, atomic::AtomicUsize},
};
use tokio::sync::mpsc::{Receiver, Sender};
use uuid::Uuid;
use crate::{
ENTRY_POINT,
app_data::{AppData, ContainerId, DockerCommand, State},
app_error::AppError,
config::Config,
ui::{GuiState, Status},
};
mod message;
pub use message::DockerMessage;
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
enum SpawnId {
Stats((ContainerId, Binate)),
Log(ContainerId),
}
impl SpawnId {
/// Extract the &ContainerId out of self
const fn get_id(&self) -> &ContainerId {
match self {
Self::Log(id) | Self::Stats((id, _)) => id,
}
}
}
/// Cpu & Mem stats take twice as long as the update interval to get a value, so will have two being executed at the same time
/// SpawnId::Stats takes container_id and binate value to enable both cycles of the same container_id to be inserted into the hashmap
/// Binate value is toggled when all handles have been spawned off
/// Also effectively means that the minimum docker_update interval will be 1000ms
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
enum Binate {
One,
Two,
}
impl Binate {
const fn toggle(self) -> Self {
match self {
Self::One => Self::Two,
Self::Two => Self::One,
}
}
}
pub struct DockerData {
app_data: Arc<Mutex<AppData>>,
binate: Binate,
config: Config,
docker: Arc<Docker>,
gui_state: Arc<Mutex<GuiState>>,
receiver: Receiver<DockerMessage>,
spawns: Arc<Mutex<HashSet<SpawnId>>>,
}
impl DockerData {
/// Use docker stats to calculate current cpu usage
#[allow(clippy::cast_precision_loss)]
fn calculate_usage(stats: &ContainerStatsResponse) -> f64 {
let mut cpu_percentage = 0.0;
let total_usage = stats.precpu_stats.as_ref().map_or(0, |i| {
i.cpu_usage
.as_ref()
.map_or(0, |i| i.total_usage.unwrap_or_default())
});
let cpu_delta = stats.cpu_stats.as_ref().map_or(0, |i| {
i.cpu_usage.as_ref().map_or(0, |i| {
i.total_usage
.unwrap_or_default()
.saturating_sub(total_usage)
})
}) as f64;
if let (Some(Some(cpu_stats_usage)), Some(Some(precpu_stats_usage))) = (
stats.cpu_stats.as_ref().map(|i| i.system_cpu_usage),
stats.precpu_stats.as_ref().map(|i| i.system_cpu_usage),
) {
let system_delta = cpu_stats_usage.saturating_sub(precpu_stats_usage) as f64;
let online_cpus = f64::from(stats.cpu_stats.as_ref().map_or(0, |i| {
i.online_cpus.unwrap_or_else(|| {
u32::try_from(
stats
.cpu_stats
.clone()
.unwrap_or_default()
.cpu_usage
.unwrap_or_default()
.percpu_usage
.as_ref()
.map_or(0, std::vec::Vec::len),
)
.unwrap_or_default()
})
}));
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 own thread
/// remove if from spawns hashmap when complete
/// 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 own thread
/// remove if from spawns hashmap when complete
async fn update_container_stat(
app_data: Arc<Mutex<AppData>>,
docker: Arc<Docker>,
state: State,
spawn_id: SpawnId,
spawns: Arc<Mutex<HashSet<SpawnId>>>,
) {
let id = spawn_id.get_id();
let mut stream = docker
.stats(
id.get(),
Some(StatsOptions {
stream: false,
one_shot: false,
}),
)
.take(1);
while let Some(Ok(stats)) = stream.next().await {
// Memory stats are only collected if the container is alive - is this the behaviour we want?
let (mem_stat, cpu_stats) = if state.is_alive() {
let mem_cache = stats.memory_stats.as_ref().map_or(&0, |i| {
i.stats
.as_ref()
.map_or(&0, |i| i.get("inactive_file").unwrap_or(&0))
});
(
Some(
stats
.memory_stats
.as_ref()
.map_or(0, |i| i.usage.unwrap_or_default())
.saturating_sub(*mem_cache),
),
Some(Self::calculate_usage(&stats)),
)
} else {
(None, None)
};
// TODO is hardcoded eth0 a good idea here?
let (rx, tx) = stats.networks.as_ref().map_or((0, 0), |i| {
i.get("eth0").map_or((0, 0), |x| {
(
x.rx_bytes.unwrap_or_default(),
x.tx_bytes.unwrap_or_default(),
)
})
});
app_data.lock().update_stats_by_id(
id,
cpu_stats,
mem_stat,
stats
.memory_stats
.unwrap_or_default()
.limit
.unwrap_or_default(),
rx,
tx,
);
}
spawns.lock().remove(&spawn_id);
}
/// Update all stats, spawn each container into own tokio::spawn thread
fn update_all_container_stats(&mut self) {
let all_ids = self.app_data.lock().get_all_id_state();
for (state, id) in all_ids {
let spawn_id = SpawnId::Stats((id, self.binate));
if !self.spawns.lock().contains(&spawn_id) {
let app_data = Arc::clone(&self.app_data);
let docker = Arc::clone(&self.docker);
let spawns = Arc::clone(&self.spawns);
tokio::spawn(Self::update_container_stat(
app_data, docker, state, spawn_id, spawns,
));
}
}
self.binate = self.binate.toggle();
}
/// Get all current containers, handle into ContainerItem in the app_data struct rather than here
/// Just make sure that items sent are guaranteed to have an id
/// If in a containerised runtime, will ignore any container that uses the `/app/oxker` as an entry point, unless the `-s` flag is set
async fn update_all_containers(&self) {
let containers = self
.docker
.list_containers(Some(ListContainersOptions {
all: true,
..Default::default()
}))
.await
.unwrap_or_default();
let output = containers
.into_iter()
.filter_map(|f| match f.id {
Some(_) => {
if self.config.in_container
&& f.command
.as_ref()
.is_some_and(|c| c.starts_with(ENTRY_POINT))
&& self.config.show_self
{
None
} else {
Some(f)
}
}
None => None,
})
.collect::<Vec<ContainerSummary>>();
self.app_data.lock().update_containers(output);
}
/// Update single container logs
/// remove it from spawns hashmap when complete
async fn update_log(
app_data: Arc<Mutex<AppData>>,
docker: Arc<Docker>,
id: ContainerId,
since: u64,
spawns: Arc<Mutex<HashSet<SpawnId>>>,
stderr: bool,
) {
let options = Some(LogsOptions {
stdout: true,
stderr,
timestamps: true,
since: i32::try_from(since).unwrap_or_default(),
..Default::default()
});
let mut logs = docker.logs(id.get(), options);
let mut output = vec![];
while let Some(Ok(value)) = logs.next().await {
let data = value.to_string();
if !data.trim().is_empty() {
output.push(data);
}
}
app_data.lock().update_log_by_id(output, &id);
spawns.lock().remove(&SpawnId::Log(id));
}
/// Update all logs, spawn each container into own tokio::spawn thread
fn init_all_logs(&self, all_ids: Vec<(State, ContainerId)>) -> Arc<AtomicUsize> {
let init = Arc::new(AtomicUsize::new(0));
for (_, id) in all_ids {
let app_data: Arc<parking_lot::lock_api::Mutex<parking_lot::RawMutex, AppData>> =
Arc::clone(&self.app_data);
let docker = Arc::clone(&self.docker);
let spawns = Arc::clone(&self.spawns);
let std_err = self.config.show_std_err;
let init = Arc::clone(&init);
self.spawns.lock().insert(SpawnId::Log(id.clone()));
tokio::spawn(async move {
Self::update_log(app_data, docker, id, 0, spawns, std_err).await;
init.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
});
}
init
}
/// Initialize docker container data, before any messages are received
async fn initialise_container_data(&mut self) {
self.gui_state.lock().status_push(Status::Init);
let loading_uuid = Uuid::new_v4();
GuiState::start_loading_animation(&self.gui_state, loading_uuid);
self.update_all_containers().await;
let all_ids = self.app_data.lock().get_all_id_state();
let all_ids_len = all_ids.len();
let init = self.init_all_logs(all_ids);
self.update_all_container_stats();
while init.load(std::sync::atomic::Ordering::SeqCst) != all_ids_len {
self.app_data.lock().sort_containers();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
self.gui_state.lock().stop_loading_animation(loading_uuid);
self.gui_state.lock().status_del(Status::Init);
}
/// Update all cpu_mem, and selected container log (if a log update join_handle isn't currently being executed)
async fn update_everything(&mut self) {
self.update_all_containers().await;
if let Some(container) = self.app_data.lock().get_selected_container() {
let last_updated = container.last_updated;
let spawn_id = SpawnId::Log(container.id.clone());
// Only spawn if not already spawned with a given id/binate pair
if !self.spawns.lock().contains(&spawn_id) {
self.spawns.lock().insert(spawn_id.clone());
tokio::spawn(Self::update_log(
Arc::clone(&self.app_data),
Arc::clone(&self.docker),
container.id.clone(),
last_updated,
Arc::clone(&self.spawns),
self.config.show_std_err,
));
}
}
self.update_all_container_stats();
self.app_data.lock().sort_containers();
}
/// Set the global error as the docker error, and set gui_state to error
fn set_error(
app_data: &Arc<Mutex<AppData>>,
error: DockerCommand,
gui_state: &Arc<Mutex<GuiState>>,
) {
app_data
.lock()
.set_error(AppError::DockerCommand(error), gui_state, Status::Error);
}
/// Execute docker commands (start, stop etc) on it's own tokio thread
async fn execute_command(&mut self, control: DockerCommand, id: ContainerId) {
let (app_data, docker, gui_state) = (
Arc::clone(&self.app_data),
Arc::clone(&self.docker),
Arc::clone(&self.gui_state),
);
tokio::spawn(async move {
let uuid = Uuid::new_v4();
GuiState::start_loading_animation(&gui_state, uuid);
if match control {
DockerCommand::Delete => {
gui_state.lock().set_delete_container(None);
docker
.remove_container(
id.get(),
Some(RemoveContainerOptions {
v: false,
force: true,
link: false,
}),
)
.await
}
DockerCommand::Pause => docker.pause_container(id.get()).await,
DockerCommand::Restart => {
docker
.restart_container(id.get(), None::<RestartContainerOptions>)
.await
}
DockerCommand::Resume => docker.unpause_container(id.get()).await,
DockerCommand::Start => {
docker
.start_container(id.get(), None::<StartContainerOptions>)
.await
}
DockerCommand::Stop => {
docker
.stop_container(id.get(), None::<StopContainerOptions>)
.await
}
}
.is_err()
{
Self::set_error(&app_data, control, &gui_state);
}
gui_state.lock().stop_loading_animation(uuid);
});
self.update_everything().await;
}
/// Handle incoming messages, container controls & all container information update
/// Spawn Docker commands off into own thread
async fn message_handler(&mut self) {
while let Some(message) = self.receiver.recv().await {
match message {
DockerMessage::ConfirmDelete(id) => {
self.gui_state.lock().set_delete_container(Some(id));
}
DockerMessage::Control((command, id)) => self.execute_command(command, id).await,
DockerMessage::Exec(docker_tx) => {
docker_tx.send(Arc::clone(&self.docker)).ok();
}
DockerMessage::Update => self.update_everything().await,
DockerMessage::Inspect(id) => {
let t = self
.docker
.inspect_container(id.get(), Some(InspectContainerOptions { size: true }))
.await;
if let Ok(t) = t {
self.app_data.lock().set_inspect_data(t);
self.gui_state.lock().status_push(Status::Inspect);
} else {
// Set error here, can't inspect container
}
}
}
}
}
/// Send an update message every x ms, where x is the args.docker_interval
fn heartbeat(config: &Config, docker_tx: Sender<DockerMessage>) {
let update_duration =
std::time::Duration::from_millis(u64::from(config.docker_interval_ms));
let mut now = std::time::Instant::now();
tokio::spawn(async move {
loop {
docker_tx.send(DockerMessage::Update).await.ok();
if let Some(to_sleep) = update_duration.checked_sub(now.elapsed()) {
tokio::time::sleep(to_sleep).await;
}
now = std::time::Instant::now();
}
});
}
/// Initialise self, and start the message receiving loop
pub async fn start(
app_data: Arc<Mutex<AppData>>,
docker: Docker,
docker_rx: Receiver<DockerMessage>,
docker_tx: Sender<DockerMessage>,
gui_state: Arc<Mutex<GuiState>>,
) {
let args = app_data.lock().config.clone();
if app_data.lock().get_error().is_none() {
let mut inner = Self {
app_data,
config: args,
binate: Binate::One,
docker: Arc::new(docker),
gui_state,
receiver: docker_rx,
spawns: Arc::new(Mutex::new(HashSet::new())),
};
inner.initialise_container_data().await;
Self::heartbeat(&inner.config, docker_tx);
inner.message_handler().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
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use bollard::secret::{ContainerCpuStats, ContainerCpuUsage};
use super::*;
fn gen_stats() -> ContainerStatsResponse {
ContainerStatsResponse {
read: None,
os_type: None,
preread: None,
num_procs: Some(1),
pids_stats: None,
networks: None,
memory_stats: None,
blkio_stats: None,
cpu_stats: Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
}),
precpu_stats: Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
}),
storage_stats: None,
name: None,
id: None,
}
}
#[test]
fn test_calculate_usage_50() {
let mut stats = gen_stats();
stats.precpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
});
stats.cpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![150]),
usage_in_usermode: Some(20),
total_usage: Some(150),
usage_in_kernelmode: Some(30),
}),
system_cpu_usage: Some(500),
online_cpus: Some(1),
throttling_data: None,
});
let cpu_percentage = DockerData::calculate_usage(&stats);
assert_eq!(50.0, cpu_percentage);
}
#[test]
fn test_calculate_usage_25() {
let mut stats = gen_stats();
stats.precpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
});
stats.cpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![75]),
usage_in_usermode: Some(20),
total_usage: Some(125),
usage_in_kernelmode: Some(30),
}),
system_cpu_usage: Some(500),
online_cpus: Some(1),
throttling_data: None,
});
let cpu_percentage = DockerData::calculate_usage(&stats);
assert_eq!(25.0, cpu_percentage);
}
#[test]
fn test_calculate_usage_75() {
let mut stats = gen_stats();
stats.precpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
});
stats.cpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![175]),
usage_in_usermode: Some(20),
total_usage: Some(175),
usage_in_kernelmode: Some(30),
}),
system_cpu_usage: Some(500),
online_cpus: Some(1),
throttling_data: None,
});
let cpu_percentage = DockerData::calculate_usage(&stats);
assert_eq!(75.0, cpu_percentage);
}
#[test]
fn test_calculate_usage_100() {
let mut stats = gen_stats();
stats.precpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
});
stats.cpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![200]),
usage_in_usermode: Some(20),
total_usage: Some(200),
usage_in_kernelmode: Some(30),
}),
system_cpu_usage: Some(500),
online_cpus: Some(1),
throttling_data: None,
});
let cpu_percentage = DockerData::calculate_usage(&stats);
assert_eq!(100.0, cpu_percentage);
}
#[test]
fn test_calculate_usage_175() {
let mut stats = gen_stats();
stats.precpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![50]),
usage_in_usermode: Some(10),
total_usage: Some(100),
usage_in_kernelmode: Some(20),
}),
system_cpu_usage: Some(400),
online_cpus: Some(1),
throttling_data: None,
});
stats.cpu_stats = Some(ContainerCpuStats {
cpu_usage: Some(ContainerCpuUsage {
percpu_usage: Some(vec![275]),
usage_in_usermode: Some(20),
total_usage: Some(275),
usage_in_kernelmode: Some(30),
}),
system_cpu_usage: Some(500),
online_cpus: Some(1),
throttling_data: None,
});
let cpu_percentage = DockerData::calculate_usage(&stats);
assert_eq!(175.0, cpu_percentage);
}
}
-360
View File
@@ -1,360 +0,0 @@
use std::{
io::{Read, Write},
sync::{Arc, atomic::AtomicBool, mpsc::Sender},
};
use bollard::{
Docker,
exec::{CreateExecOptions, ResizeExecOptions, StartExecOptions, StartExecResults},
};
use crossterm::terminal::enable_raw_mode;
use futures_util::StreamExt;
use parking_lot::Mutex;
use ratatui::layout::Size;
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncWriteExt},
};
use tokio_util::sync::CancellationToken;
use crate::{
app_data::{AppData, ContainerId, RunningState, State},
app_error::AppError,
};
/// TTY location
const TTY: &str = "/dev/tty";
/// This will be the start of a docker exec message if one is unable to actually exec into the container
const OCI_ERROR: &str = "OCI runtime exec failed";
/// Set the cursor position on the screen to (0,0)
const CURSOR_POS: &str = "\x1B[J\x1B[H";
/// This needs to be written to stdout when exiting the exec mode, else the input handler thread gets confused,
/// see https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
const KEYBOARD_PROTO: &str = "\x1B[?u\x1B[c";
mod command {
pub const PWD: &str = "pwd";
pub const DOCKER: &str = "docker";
pub const EXEC: &str = "exec";
pub const SH: &str = "sh";
pub const IT: &str = "-it";
}
/// Currently known byte output after writing KEYBOARD_PROTO to stdout
/// valid arm: [91, 63, 54, 49, 59, 54, 59, 55, 59, 50, 50, 59, 50, 51, 59, 50, 52, 59, 50, 56, 59, 51, 50, 59,52, 50] => [?61;6;7;22;23;24;28;32;2
/// valid x86: [91, 63, 49, 59, 50, 99] => [?1;2c
/// invalid x86: [91, 63, 49, 59, 48, 99] => [?1;0c
enum ByteOutput {
Arm,
X86,
}
impl ByteOutput {
const fn len(&self) -> usize {
match self {
Self::Arm => 26,
Self::X86 => 6,
}
}
const fn last(&self) -> &[u8] {
match self {
Self::Arm => &[50],
Self::X86 => &[99],
}
}
}
/// Check the output from tty to see if it matches known sequence.
/// At the moment we only need to check the length and end digit, as x86 valid and invalid match in these two regards
fn byte_sequence_valid(bytes: &[u8]) -> bool {
[ByteOutput::Arm, ByteOutput::X86]
.iter()
.any(|i| i.len() == bytes.len() && bytes.ends_with(i.last()))
}
/// Check if tty is able to be written to, aka not windows
pub fn tty_readable() -> bool {
std::fs::OpenOptions::new()
.read(true)
.write(false)
.open(TTY)
.is_ok()
}
struct AsyncTTY {
rx: std::sync::mpsc::Receiver<u8>,
}
impl AsyncTTY {
/// Use an async timeout to read data from the file, and send to the "main" thread
async fn read_loop(mut f: File, tx: Sender<u8>) {
loop {
let mut buf = [0];
if tokio::time::timeout(std::time::Duration::from_millis(10), f.read_exact(&mut buf))
.await
.is_ok()
&& tx.send(buf[0]).is_err()
{
break;
}
}
}
/// Async tty reading, spawned into its own tokio thread
fn get(cancel_token: &CancellationToken) -> Option<Self> {
if tty_readable() {
let (tx, rx) = std::sync::mpsc::channel();
let cancel_token = cancel_token.to_owned();
tokio::spawn(async move {
if let Ok(f) = tokio::fs::File::open(TTY).await {
tokio::select! {
() = cancel_token.cancelled() => (),
() = Self::read_loop(f, tx) => cancel_token.cancel(),
}
}
});
Some(Self { rx })
} else {
None
}
}
}
// impl TryFrom<&Terminal<CrosstermBackend<Stdout>>> for HWU16 {
// type Error = None;
// fn try_from(terminal: &Terminal<CrosstermBackend<Stdout>>) -> Option<Self> {
// terminal.size().map_or(None, |i| {
// Some(Self {
// width: i.width,
// height: i.height,
// })
// })
// }
// }
// impl TerminalSize {
// pub fn new(terminal: &Terminal<CrosstermBackend<Stdout>>) -> Option<Self> {
// terminal.size().map_or(None, |i| {
// Some(Self {
// width: i.width,
// height: i.height,
// })
// })
// }
// }
#[derive(Debug, Clone)]
pub enum ExecMode {
// use Bollard Rust library
Internal((Arc<ContainerId>, Arc<Docker>)),
// use the external `docker-cli`
External(Arc<ContainerId>),
}
impl ExecMode {
/// Test if we can exec into the selected container, first via the Internal methods, then by the External
/// If the container is oxker, it will always return None
pub async fn new(app_data: &Arc<Mutex<AppData>>, docker: &Arc<Docker>) -> Option<Self> {
let is_oxker = app_data.lock().is_oxker();
if is_oxker {
return None;
}
let use_cli = app_data.lock().config.use_cli;
let container = app_data.lock().get_selected_container_id_state_name();
if let Some((id, state, _)) = container
&& [
State::Running(RunningState::Healthy),
State::Running(RunningState::Unhealthy),
]
.contains(&state)
{
if tty_readable()
&& !use_cli
&& let Ok(exec) = docker
.create_exec(
id.get(),
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(vec![command::PWD]),
..Default::default()
},
)
.await
&& let Ok(StartExecResults::Attached { mut output, .. }) =
docker.start_exec(&exec.id, None).await
&& let Some(Ok(msg)) = output.next().await
&& !msg.to_string().starts_with(OCI_ERROR)
{
return Some(Self::Internal((Arc::new(id), Arc::clone(docker))));
}
if let Ok(output) = std::process::Command::new(command::DOCKER)
.args([command::EXEC, id.get(), command::PWD])
.output()
&& let Ok(output) = String::from_utf8(output.stdout)
&& !output.starts_with(OCI_ERROR)
{
return Some(Self::External(Arc::new(id)));
}
}
None
}
/// exec into the container using the external docker cli, the result it just piped into oxker
fn exec_external(id: &ContainerId) {
let mut stdout = std::io::stdout();
stdout.write_all(CURSOR_POS.as_bytes()).ok();
if let Ok(mut child) = std::process::Command::new(command::DOCKER)
.args([command::EXEC, command::IT, id.get(), command::SH])
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
{
child.wait().ok();
if child.kill().is_err() {
std::process::exit(1)
}
}
}
/// Exec into the container via the Bollard library, stdout & stdin on different threads
/// Have to deal with strange output once dropped, hence the use of internal_cleanup() method
async fn exec_internal(
&self,
id: &ContainerId,
docker: &Arc<Docker>,
terminal_size: Option<Size>,
) -> Result<(), AppError> {
let cancel_token = CancellationToken::new();
if let Ok(exec_result) = docker
.create_exec(
id.get(),
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
attach_stdin: Some(true),
tty: Some(true),
cmd: Some(vec![command::SH]),
..Default::default()
},
)
.await
{
match docker
.start_exec(
&exec_result.id,
Some(StartExecOptions {
detach: false,
..Default::default()
}),
)
.await
{
Ok(StartExecResults::Attached {
mut output,
mut input,
}) => {
if let Some(tty) = AsyncTTY::get(&cancel_token) {
tokio::spawn(async move {
enable_raw_mode().ok();
let mut stdout = std::io::stdout();
stdout.write_all(CURSOR_POS.as_bytes()).ok();
stdout.flush().ok();
while let Some(Ok(x)) = output.next().await {
stdout.write_all(&x.into_bytes()).ok();
stdout.flush().ok();
}
cancel_token.cancel();
});
if let Some(terminal_size) = terminal_size {
docker
.resize_exec(
&exec_result.id,
ResizeExecOptions {
height: terminal_size.height,
width: terminal_size.width,
},
)
.await
.ok();
}
while let Ok(x) = tty.rx.recv() {
input.write_all(&[x]).await.ok();
}
self.internal_cleanup()?;
}
}
_ => {
return Err(AppError::Terminal);
}
}
}
Ok(())
}
/// This is the fix for key pressed not being handled correctly on quit
/// It writes a special message to the stdout, and then listens out for a valid response
/// afterwhich it's assumes that we're completely done with TTY
fn internal_cleanup(&self) -> Result<(), AppError> {
match self {
Self::External(_) => Ok(()),
Self::Internal(_) => {
let waiting = Arc::new(AtomicBool::new(true));
let waiting_thread = Arc::clone(&waiting);
std::thread::spawn(move || {
let mut bytes = Vec::with_capacity(26);
while waiting_thread.load(std::sync::atomic::Ordering::SeqCst) {
let mut buf = [0];
if let Ok(mut f) = std::fs::File::open(TTY) {
if f.read_exact(&mut buf).is_err() {
waiting_thread.store(false, std::sync::atomic::Ordering::SeqCst);
}
bytes.push(buf[0]);
if byte_sequence_valid(&bytes) {
waiting_thread.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
}
});
let mut stdout = std::io::stdout();
stdout.write_all(KEYBOARD_PROTO.as_bytes()).ok();
stdout.flush().ok();
let start = std::time::Instant::now();
while waiting.load(std::sync::atomic::Ordering::SeqCst) {
if start.elapsed().as_millis() > 1500 {
waiting.store(false, std::sync::atomic::Ordering::SeqCst);
return Err(AppError::Terminal);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
Ok(())
}
}
}
pub async fn run(&self, tty_size: Option<Size>) -> Result<(), AppError> {
match self {
Self::External(id) => {
Self::exec_external(id);
Ok(())
}
Self::Internal((id, docker)) => self.exec_internal(id, docker, tty_size).await,
}
}
}
-7
View File
@@ -1,7 +0,0 @@
use crossterm::event::{KeyCode, KeyModifiers, MouseEvent};
#[derive(Debug, Clone, Copy)]
pub enum InputMessages {
ButtonPress((KeyCode, KeyModifiers)),
MouseEvent((MouseEvent, KeyModifiers)),
}
-871
View File
@@ -1,871 +0,0 @@
use std::{
fs::OpenOptions,
io::{BufWriter, Write},
sync::{Arc, atomic::AtomicBool},
time::SystemTime,
};
use bollard::query_parameters::LogsOptions;
use cansi::v3::categorise_text;
use crossterm::{
event::{DisableMouseCapture, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind},
execute,
};
use futures_util::StreamExt;
use parking_lot::Mutex;
use ratatui::layout::Rect;
use tokio::sync::mpsc::{Receiver, Sender};
use uuid::Uuid;
mod message;
use crate::{
app_data::{AppData, DockerCommand, Header, ScrollDirection},
app_error::AppError,
config,
docker_data::DockerMessage,
exec::{ExecMode, tty_readable},
ui::{DeleteButton, GuiState, SelectablePanel, Status, Ui},
};
pub use message::InputMessages;
/// Handle all input events
#[derive(Debug)]
pub struct InputHandler {
app_data: Arc<Mutex<AppData>>,
docker_tx: Sender<DockerMessage>,
keymap: config::Keymap,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
mouse_capture: bool,
rx: Receiver<InputMessages>,
}
impl InputHandler {
/// Initialize self, and running the message handling loop
pub async fn start(
app_data: Arc<Mutex<AppData>>,
docker_tx: Sender<DockerMessage>,
gui_state: Arc<Mutex<GuiState>>,
is_running: Arc<AtomicBool>,
rx: Receiver<InputMessages>,
) {
let keymap = app_data.lock().config.keymap.clone();
let mut inner = Self {
app_data,
docker_tx,
gui_state,
is_running,
keymap,
rx,
mouse_capture: true,
};
inner.message_handler().await;
}
/// check for incoming messages
async fn message_handler(&mut self) {
while let Some(message) = self.rx.recv().await {
match message {
InputMessages::ButtonPress(key) => self.button_press(key.0, key.1).await,
InputMessages::MouseEvent((mouse_event, modifider)) => {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
if contains(Status::DeleteConfirm) {
self.button_intersect(mouse_event).await;
} else if !contains(Status::Error)
&& !contains(Status::Help)
&& !contains(Status::DeleteConfirm)
&& !contains(Status::Filter)
&& !contains(Status::SearchLogs)
{
// TODO handle state where you want to scroll log search results with the mouse wheel
self.mouse_press(mouse_event, modifider);
}
}
}
}
}
/// Sort the containers by a given header
fn sort(&self, selected_header: Header) {
self.app_data.lock().set_sort_by_header(selected_header);
}
/// Send a quit message to docker, to abort all spawns, if an error is returned, set is_running to false here instead
/// If gui_status is Error or Init, then just set the is_running to false immediately, for a quicker exit
fn quit(&self) {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
if !contains(Status::Error) | !contains(Status::Init) {
self.is_running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
/// This is executed from the Delete Confirm dialog, and will send an internal message to actually remove the given container
async fn confirm_delete(&self) {
let id = self.gui_state.lock().get_delete_container();
if let Some(id) = id {
self.docker_tx
.send(DockerMessage::Control((DockerCommand::Delete, id)))
.await
.ok();
}
}
/// This is executed from the Delete Confirm dialog, and will clear the delete_container information (removes id and closes panel)
fn clear_delete(&self) {
self.gui_state.lock().set_delete_container(None);
}
async fn inspect_key(&self) {
self.app_data.lock().clear_inspect_data();
let selected = self.app_data.lock().get_selected_container().cloned();
if let Some(g) = selected {
self.docker_tx.send(DockerMessage::Inspect(g.id)).await.ok();
}
}
/// Validate that one can exec into a Docker container
async fn exec_key(&self) {
let is_oxker = self.app_data.lock().is_oxker();
if !is_oxker && tty_readable() {
let uuid = Uuid::new_v4();
GuiState::start_loading_animation(&self.gui_state, uuid);
let (sx, rx) = tokio::sync::oneshot::channel();
self.docker_tx.send(DockerMessage::Exec(sx)).await.ok();
if let Ok(docker) = rx.await {
(ExecMode::new(&self.app_data, &docker).await).map_or_else(
|| {
self.app_data.lock().set_error(
AppError::DockerExec,
&self.gui_state,
Status::Error,
);
},
|mode| {
self.gui_state.lock().set_exec_mode(mode);
},
);
}
self.gui_state.lock().stop_loading_animation(uuid);
}
}
/// Toggle the mouse capture (via input of the 'm' key)
fn mouse_capture_key(&mut self) {
let err = || {
self.app_data.lock().set_error(
AppError::MouseCapture(!self.mouse_capture),
&self.gui_state,
Status::Error,
);
};
if self.mouse_capture {
if execute!(std::io::stdout(), DisableMouseCapture).is_ok() {
self.gui_state
.lock()
.set_info_box("✖ mouse capture disabled");
} else {
err();
}
} else if Ui::enable_mouse_capture().is_ok() {
self.gui_state
.lock()
.set_info_box("✓ mouse capture enabled");
} else {
err();
}
self.mouse_capture = !self.mouse_capture;
}
/// Save the currently selected containers logs into a `[container_name]_[timestamp].log` file
async fn save_logs(&self) -> Result<(), Box<dyn std::error::Error>> {
let args = self.app_data.lock().config.clone();
let container = self.app_data.lock().get_selected_container_id_state_name();
if let Some((id, _, name)) = container
&& let Some(log_path) = args.dir_save
{
let (sx, rx) = tokio::sync::oneshot::channel();
self.docker_tx.send(DockerMessage::Exec(sx)).await?;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |i| i.as_secs());
let path = log_path.join(format!("{name}_{now}.log"));
let options = Some(LogsOptions {
stderr: true,
stdout: true,
timestamps: args.show_timestamp,
since: 0,
..Default::default()
});
let mut logs = rx.await?.logs(id.get(), options);
let mut output = vec![];
while let Some(Ok(value)) = logs.next().await {
let data = value.to_string();
if !data.trim().is_empty() {
output.push(
categorise_text(&data)
.into_iter()
.map(|i| i.text)
.collect::<String>(),
);
}
}
if !output.is_empty() {
let mut stream = BufWriter::new(
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?,
);
for line in &output {
stream.write_all(line.as_bytes())?;
}
stream.flush()?;
self.gui_state
.lock()
.set_info_box(&format!("saved to {}", path.display()));
}
}
Ok(())
}
/// Attempt to save the currently selected container logs to a file
async fn save_key(&self) {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
if !contains(Status::Logs) {
self.gui_state.lock().status_push(Status::Logs);
let uuid = Uuid::new_v4();
GuiState::start_loading_animation(&self.gui_state, uuid);
if self.save_logs().await.is_err() {
self.app_data.lock().set_error(
AppError::DockerLogs,
&self.gui_state,
Status::Error,
);
}
self.gui_state.lock().status_del(Status::Logs);
self.gui_state.lock().stop_loading_animation(uuid);
}
}
/// Send docker command, if the Commands panel is selected
async fn enter_key(&self) {
// This isn't great, just means you can't send docker commands before full initialization of the program
let panel = self.gui_state.lock().get_selected_panel();
if panel == SelectablePanel::Commands {
let option_command = self.app_data.lock().selected_docker_controls();
if let Some(command) = option_command {
// Poor way of disallowing commands to be sent to a containerised okxer
if self.app_data.lock().is_oxker_in_container() {
return;
}
let option_id = self.app_data.lock().get_selected_container_id();
if let Some(id) = option_id {
match command {
DockerCommand::Delete => self
.docker_tx
.send(DockerMessage::ConfirmDelete(id))
.await
.ok(),
_ => self
.docker_tx
.send(DockerMessage::Control((command, id)))
.await
.ok(),
};
}
}
}
}
/// If keymap.scroll_modifier is pressed, return 10, else return 1, to speed up scrolling
fn get_modifier_total(&self, modifier: KeyModifiers) -> u8 {
if modifier == self.keymap.scroll_many {
10
} else {
1
}
}
fn inspect_scroll(&self, modifier: KeyModifiers, sd: &ScrollDirection) {
for _ in 0..self.get_modifier_total(modifier) {
self.gui_state.lock().set_inspect_offset(sd);
}
}
// fn inspect_scroll(&self, modifier: KeyModifiers, sd: &ScrollDirection) {
// for _ in 0..self.get_modifier_total(modifier) {
// self.gui_state.lock().set_inspect_offset(sd);
// }
// }
fn logs_horizontal_scroll(&self, modifier: KeyModifiers, sd: &ScrollDirection) {
let panel = self.gui_state.lock().get_selected_panel();
if panel == SelectablePanel::Logs {
for _ in 0..self.get_modifier_total(modifier) {
let width = self.gui_state.lock().get_screen_width();
self.app_data.lock().logs_horizontal_scroll(sd, width);
}
}
}
/// Change the the "next" selectable panel
/// If no containers, and on Commands panel, skip to next panel, as Commands panel isn't visible in this state
fn next_panel_key(&self) {
self.gui_state.lock().selectable_panel_next(&self.app_data);
}
/// Change to previously selected panel
/// Need to skip the commands planel if there no are current containers running
fn previous_panel_key(&self) {
self.gui_state
.lock()
.selectable_panel_previous(&self.app_data);
}
fn scroll_start_key(&self) {
let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel {
SelectablePanel::Containers => self.app_data.lock().containers_start(),
SelectablePanel::Logs => self.app_data.lock().log_start(),
SelectablePanel::Commands => self.app_data.lock().docker_controls_start(),
}
}
/// Go to end of the list of the currently selected panel
fn scroll_end_key(&self) {
let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel {
SelectablePanel::Containers => self.app_data.lock().containers_end(),
SelectablePanel::Logs => self.app_data.lock().log_end(),
SelectablePanel::Commands => self.app_data.lock().docker_controls_end(),
}
}
/// Actions to take when in Help status active
fn handle_help(&mut self, key_code: KeyCode) {
if self.keymap.clear.0 == key_code
|| self.keymap.clear.1 == Some(key_code)
|| self.keymap.toggle_help.0 == key_code
|| self.keymap.toggle_help.1 == Some(key_code)
{
self.gui_state.lock().status_del(Status::Help);
}
if self.keymap.toggle_mouse_capture.0 == key_code
|| self.keymap.toggle_mouse_capture.1 == Some(key_code)
{
self.mouse_capture_key();
}
}
/// Actions to take when Error status active
fn handle_error(&self, key_code: KeyCode) {
if self.keymap.clear.0 == key_code || self.keymap.clear.1 == Some(key_code) {
self.app_data.lock().remove_error();
self.gui_state.lock().status_del(Status::Error);
}
}
/// Actions to take when Delete status active
async fn handle_delete(&self, key_code: KeyCode) {
if self.keymap.delete_confirm.0 == key_code
|| self.keymap.delete_confirm.1 == Some(key_code)
{
self.confirm_delete().await;
} else if self.keymap.delete_deny.0 == key_code
|| self.keymap.delete_deny.1 == Some(key_code)
|| self.keymap.clear.0 == key_code
|| self.keymap.clear.1 == Some(key_code)
{
self.clear_delete();
}
}
/// Actions to take when Filter status active
fn handle_search_logs(&self, key_code: KeyCode, modifier: KeyModifiers) {
match key_code {
KeyCode::Esc => {
self.app_data.lock().logs_search_clear();
self.gui_state.lock().status_del(Status::SearchLogs);
}
_ if KeyCode::Enter == key_code
|| self.keymap.log_search_mode.0 == key_code
|| self.keymap.log_search_mode.1 == Some(key_code) =>
{
self.gui_state.lock().status_del(Status::SearchLogs);
}
_ if self.keymap.scroll_back.0 == key_code
|| self.keymap.scroll_back.1 == Some(key_code) =>
{
self.logs_horizontal_scroll(modifier, &ScrollDirection::Up);
}
_ if self.keymap.scroll_forward.0 == key_code
|| self.keymap.scroll_forward.1 == Some(key_code) =>
{
self.logs_horizontal_scroll(modifier, &ScrollDirection::Down);
}
_ if self.keymap.scroll_down.0 == key_code => {
self.app_data
.lock()
.log_search_scroll(&ScrollDirection::Down);
// TODO should only do this is log_search_scroll returns some
// Need to wait til app_data and gui_data is combined
self.gui_state
.lock()
.set_logs_panel_selected(&self.app_data);
//
}
_ if self.keymap.scroll_up.0 == key_code => {
self.app_data.lock().log_search_scroll(&ScrollDirection::Up);
// TODO should only do this is log_search_scroll returns some
// Need to wait til app_data and gui_data is combined
self.gui_state
.lock()
.set_logs_panel_selected(&self.app_data);
}
// handle up and down keys
KeyCode::Backspace => {
self.app_data.lock().log_search_pop();
}
KeyCode::Char(x) => {
self.app_data.lock().log_search_push(x);
}
_ => (),
}
}
/// Actions to take when Filter status active
fn handle_inspect(&mut self, key_code: KeyCode, modifier: KeyModifiers) {
match key_code {
_ if self.keymap.inspect.0 == key_code
|| self.keymap.inspect.1 == Some(key_code)
|| self.keymap.clear.0 == key_code
|| self.keymap.clear.1 == Some(key_code) =>
{
self.app_data.lock().clear_inspect_data();
self.gui_state.lock().clear_inspect_offset();
self.gui_state.lock().status_del(Status::Inspect);
}
_ if self.keymap.scroll_down.0 == key_code
|| self.keymap.scroll_down.1 == Some(key_code) =>
{
self.inspect_scroll(modifier, &ScrollDirection::Down);
}
_ if self.keymap.scroll_up.0 == key_code
|| self.keymap.scroll_up.1 == Some(key_code) =>
{
self.inspect_scroll(modifier, &ScrollDirection::Up);
}
_ if self.keymap.scroll_forward.0 == key_code
|| self.keymap.scroll_forward.1 == Some(key_code) =>
{
self.inspect_scroll(modifier, &ScrollDirection::Right);
}
_ if self.keymap.scroll_back.0 == key_code
|| self.keymap.scroll_back.1 == Some(key_code) =>
{
self.inspect_scroll(modifier, &ScrollDirection::Left);
}
_ if self.keymap.toggle_mouse_capture.0 == key_code
|| self.keymap.toggle_mouse_capture.1 == Some(key_code) =>
{
self.mouse_capture_key();
}
_ if self.keymap.scroll_start.0 == key_code
|| self.keymap.scroll_start.1 == Some(key_code) =>
{
self.gui_state.lock().clear_inspect_offset();
}
_ if self.keymap.scroll_end.0 == key_code
|| self.keymap.scroll_end.1 == Some(key_code) =>
{
self.gui_state.lock().set_inspect_offset_y_to_max();
}
_ => (),
}
}
/// Actions to take when Filter status active
fn handle_filter(&self, key_code: KeyCode) {
match key_code {
KeyCode::Esc => {
self.app_data.lock().filter_term_clear();
self.gui_state.lock().status_del(Status::Filter);
}
_ if KeyCode::Enter == key_code
|| self.keymap.filter_mode.0 == key_code
|| self.keymap.filter_mode.1 == Some(key_code) =>
{
self.gui_state.lock().status_del(Status::Filter);
}
KeyCode::Backspace => {
self.app_data.lock().filter_term_pop();
}
KeyCode::Char(x) => {
self.app_data.lock().filter_term_push(x);
}
KeyCode::Right => {
self.app_data.lock().filter_by_next();
}
KeyCode::Left => {
self.app_data.lock().filter_by_prev();
}
_ => (),
}
}
/// Handle input that refers to the sorting of columns
fn handle_sort(&self, key_code: KeyCode) {
match key_code {
_ if self.keymap.force_redraw.0 == key_code
|| self.keymap.force_redraw.1 == Some(key_code) =>
{
self.gui_state.lock().set_clear();
}
_ if self.keymap.sort_reset.0 == key_code
|| self.keymap.sort_reset.1 == Some(key_code) =>
{
self.app_data.lock().reset_sorted();
}
_ if self.keymap.sort_by_name.0 == key_code
|| self.keymap.sort_by_name.1 == Some(key_code) =>
{
self.sort(Header::Name);
}
_ if self.keymap.sort_by_state.0 == key_code
|| self.keymap.sort_by_state.1 == Some(key_code) =>
{
self.sort(Header::State);
}
_ if self.keymap.sort_by_status.0 == key_code
|| self.keymap.sort_by_status.1 == Some(key_code) =>
{
self.sort(Header::Status);
}
_ if self.keymap.sort_by_cpu.0 == key_code
|| self.keymap.sort_by_cpu.1 == Some(key_code) =>
{
self.sort(Header::Cpu);
}
_ if self.keymap.sort_by_memory.0 == key_code
|| self.keymap.sort_by_memory.1 == Some(key_code) =>
{
self.sort(Header::Memory);
}
_ if self.keymap.sort_by_id.0 == key_code
|| self.keymap.sort_by_id.1 == Some(key_code) =>
{
self.sort(Header::Id);
}
_ if self.keymap.sort_by_image.0 == key_code
|| self.keymap.sort_by_image.1 == Some(key_code) =>
{
self.sort(Header::Image);
}
_ if self.keymap.sort_by_rx.0 == key_code
|| self.keymap.sort_by_rx.1 == Some(key_code) =>
{
self.sort(Header::Rx);
}
_ if self.keymap.sort_by_tx.0 == key_code
|| self.keymap.sort_by_tx.1 == Some(key_code) =>
{
self.sort(Header::Tx);
}
_ => (),
}
}
// Increase the log panel height
fn log_panel_height_increase(&self) {
self.gui_state.lock().log_height_increase();
}
// Decrease the log panel height
fn log_panel_height_decrease(&self) {
self.gui_state.lock().log_height_decrease();
}
// Toggle visibility of the log panel
fn log_panel_toggle(&self) {
self.gui_state.lock().toggle_show_logs();
}
/// Handle button presses in all other scenarios
#[allow(clippy::cognitive_complexity)]
async fn handle_others(&mut self, key_code: KeyCode, modifier: KeyModifiers) {
self.handle_sort(key_code);
// shift key plus arrows
match key_code {
_ if self.keymap.exec.0 == key_code || self.keymap.exec.1 == Some(key_code) => {
self.exec_key().await;
}
_ if self.keymap.toggle_help.0 == key_code
|| self.keymap.toggle_help.1 == Some(key_code) =>
{
self.gui_state.lock().status_push(Status::Help);
}
_ if self.keymap.toggle_mouse_capture.0 == key_code
|| self.keymap.toggle_mouse_capture.1 == Some(key_code) =>
{
self.mouse_capture_key();
}
_ if self.keymap.log_section_height_decrease.0 == key_code
|| self.keymap.log_section_height_decrease.1 == Some(key_code) =>
{
self.log_panel_height_decrease();
}
_ if self.keymap.log_section_height_increase.0 == key_code
|| self.keymap.log_section_height_increase.1 == Some(key_code) =>
{
self.log_panel_height_increase();
}
_ if self.keymap.log_section_toggle.0 == key_code
|| self.keymap.log_section_toggle.1 == Some(key_code) =>
{
self.log_panel_toggle();
}
_ if self.keymap.save_logs.0 == key_code
|| self.keymap.save_logs.1 == Some(key_code) =>
{
self.save_key().await;
}
_ if self.keymap.inspect.0 == key_code || self.keymap.inspect.1 == Some(key_code) => {
self.inspect_key().await;
}
_ if self.keymap.select_next_panel.0 == key_code
|| self.keymap.select_next_panel.1 == Some(key_code) =>
{
self.next_panel_key();
}
_ if self.keymap.select_previous_panel.0 == key_code
|| self.keymap.select_previous_panel.1 == Some(key_code) =>
{
self.previous_panel_key();
}
_ if self.keymap.scroll_start.0 == key_code
|| self.keymap.scroll_start.1 == Some(key_code) =>
{
self.scroll_start_key();
}
_ if self.keymap.scroll_end.0 == key_code
|| self.keymap.scroll_end.1 == Some(key_code) =>
{
self.scroll_end_key();
}
_ if self.keymap.scroll_up.0 == key_code
|| self.keymap.scroll_up.1 == Some(key_code) =>
{
self.scroll(modifier, &ScrollDirection::Up);
}
_ if self.keymap.scroll_down.0 == key_code
|| self.keymap.scroll_down.1 == Some(key_code) =>
{
self.scroll(modifier, &ScrollDirection::Down);
}
_ if self.keymap.filter_mode.0 == key_code
|| self.keymap.filter_mode.1 == Some(key_code) =>
{
self.gui_state.lock().status_push(Status::Filter);
self.docker_tx.send(DockerMessage::Update).await.ok();
}
_ if self.keymap.log_search_mode.0 == key_code
|| self.keymap.log_search_mode.1 == Some(key_code) =>
{
if !self.gui_state.lock().get_show_logs() {
self.gui_state.lock().toggle_show_logs();
}
self.gui_state.lock().status_push(Status::SearchLogs);
}
_ if self.keymap.scroll_back.0 == key_code
|| self.keymap.scroll_back.1 == Some(key_code) =>
{
self.logs_horizontal_scroll(modifier, &ScrollDirection::Up);
// self.logs_back(modifier);
}
_ if self.keymap.scroll_forward.0 == key_code
|| self.keymap.scroll_forward.1 == Some(key_code) =>
{
self.logs_horizontal_scroll(modifier, &ScrollDirection::Down);
}
KeyCode::Enter => self.enter_key().await,
_ => (),
}
}
/// Handle keyboard button events
async fn button_press(&mut self, key_code: KeyCode, key_modifier: KeyModifiers) {
let status = self.gui_state.lock().get_status();
let contains = |s: Status| status.contains(&s);
let contains_error = contains(Status::Error);
let contains_help = contains(Status::Help);
let contains_exec = contains(Status::Exec);
let contains_filter = contains(Status::Filter);
let contains_delete = contains(Status::DeleteConfirm);
let contains_search_logs = contains(Status::SearchLogs);
let contains_inspect = contains(Status::Inspect);
if !contains_exec {
let is_q = || key_code == self.keymap.quit.0 || Some(key_code) == self.keymap.quit.1;
if key_modifier == KeyModifiers::CONTROL && key_code == KeyCode::Char('c')
|| is_q() && !contains_filter && !contains_search_logs
{
// Always just quit on Ctrl + c/C or q/Q, unless in filter/search_logs mode, i.e. when user inmput can include the q key
self.quit();
}
if contains_error {
self.handle_error(key_code);
} else if contains_help {
self.handle_help(key_code);
} else if contains_filter {
self.handle_filter(key_code);
} else if contains_search_logs {
self.handle_search_logs(key_code, key_modifier);
} else if contains_delete {
self.handle_delete(key_code).await;
} else if contains_inspect {
self.handle_inspect(key_code, key_modifier);
} else {
self.handle_others(key_code, key_modifier).await;
}
}
}
/// Check if a button press interacts with either the yes or no buttons in the delete container confirm window
async fn button_intersect(&self, mouse_event: MouseEvent) {
if mouse_event.kind == MouseEventKind::Down(MouseButton::Left) {
let intersect = self.gui_state.lock().get_intersect_button(Rect::new(
mouse_event.column,
mouse_event.row,
1,
1,
));
if let Some(button) = intersect {
match button {
DeleteButton::Confirm => self.confirm_delete().await,
DeleteButton::Cancel => self.clear_delete(),
}
}
}
}
/// Handle mouse button events
fn mouse_press(&self, mouse_event: MouseEvent, modifier: KeyModifiers) {
let status = self.gui_state.lock().get_status();
if status.contains(&Status::Inspect) {
match mouse_event.kind {
MouseEventKind::ScrollDown => self.inspect_scroll(modifier, &ScrollDirection::Down),
MouseEventKind::ScrollUp => self.inspect_scroll(modifier, &ScrollDirection::Up),
MouseEventKind::ScrollRight => {
self.inspect_scroll(modifier, &ScrollDirection::Right)
}
MouseEventKind::ScrollLeft => self.inspect_scroll(modifier, &ScrollDirection::Left),
_ => (),
}
} else if status.contains(&Status::Help) {
let mouse_point = Rect::new(mouse_event.column, mouse_event.row, 1, 1);
let help_intersect = self.gui_state.lock().get_intersect_help(mouse_point);
if help_intersect {
self.gui_state.lock().status_del(Status::Help);
}
} else {
match mouse_event.kind {
MouseEventKind::ScrollUp => self.scroll(modifier, &ScrollDirection::Up),
MouseEventKind::ScrollDown => self.scroll(modifier, &ScrollDirection::Down),
// TODO left and right for log offsets
MouseEventKind::Down(MouseButton::Left) => {
let mouse_point = Rect::new(mouse_event.column, mouse_event.row, 1, 1);
let header = self.gui_state.lock().get_intersect_header(mouse_point);
if let Some(header) = header {
self.sort(header);
}
let help_intersect = self.gui_state.lock().get_intersect_help(mouse_point);
if help_intersect {
self.gui_state.lock().status_push(Status::Help);
}
self.gui_state.lock().check_panel_intersect(mouse_point);
}
_ => (),
}
}
}
/// Change state to next, depending which panel is currently in focus
fn scroll(&self, modifier: KeyModifiers, scroll: &ScrollDirection) {
let status = self.gui_state.lock().get_status();
if status.contains(&Status::SearchLogs) {
self.app_data.lock().log_search_scroll(scroll);
} else {
let selected_panel = self.gui_state.lock().get_selected_panel();
match selected_panel {
SelectablePanel::Containers => {
for _ in 0..self.get_modifier_total(modifier) {
self.app_data.lock().containers_scroll(scroll);
}
}
SelectablePanel::Logs => {
for _ in 0..self.get_modifier_total(modifier) {
self.app_data.lock().log_scroll(scroll);
}
}
SelectablePanel::Commands => self.app_data.lock().docker_controls_scroll(scroll),
}
}
}
}
-269
View File
@@ -1,269 +0,0 @@
// #![allow(unused)]
// Zigbuild is stuck on 1.87.0, which means Mac builds won't work when using collapsible ifs
use app_data::AppData;
use app_error::AppError;
use bollard::{API_DEFAULT_VERSION, Docker};
use config::Config;
use docker_data::DockerData;
use input_handler::InputMessages;
use parking_lot::Mutex;
use std::{
process,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{Level, error, info};
mod app_data;
mod app_error;
mod config;
mod docker_data;
mod exec;
mod input_handler;
mod ui;
use ui::{GuiState, Rerender, Status, Ui};
use crate::docker_data::DockerMessage;
/// This is the entry point when running as a Docker Container, and is used, in conjunction with the `CONTAINER_ENV` ENV, to check if we are running as a Docker Container
const ENTRY_POINT: &str = "/app/oxker";
const ENV_KEY: &str = "OXKER_RUNTIME";
const ENV_VALUE: &str = "container";
const DOCKER_HOST: &str = "DOCKER_HOST";
/// Enable tracing, only really used in debug mode, for now
/// write to file if `-g` is set?
fn setup_tracing() {
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
}
/// Read the optional docker_host path
/// Bollard will use DOCKER_HOST env, so might be pointless here, although it will fix it's priority over any config setting
fn read_docker_host(config: &Config) -> Option<String> {
if let Some(x) = &config.host {
Some(x.to_string())
} else if let Ok(env) = std::env::var(DOCKER_HOST)
&& !env.trim().is_empty()
{
Some(env)
} else {
None
}
}
/// Create docker daemon handler, and only spawn up the docker data handler if a ping returns non-error
async fn docker_init(
app_data: &Arc<Mutex<AppData>>,
docker_rx: Receiver<DockerMessage>,
docker_tx: Sender<DockerMessage>,
gui_state: &Arc<Mutex<GuiState>>,
) {
let host = read_docker_host(&app_data.lock().config);
if let Ok(docker) = host
.as_ref()
.map_or_else(Docker::connect_with_defaults, |host| {
Docker::connect_with_socket(host, 120, API_DEFAULT_VERSION)
})
&& docker.ping().await.is_ok()
{
tokio::spawn(DockerData::start(
Arc::clone(app_data),
docker,
docker_rx,
docker_tx,
Arc::clone(gui_state),
));
} else {
app_data.lock().set_error(
AppError::DockerConnect,
gui_state,
Status::DockerConnect(host),
);
}
}
/// Create data for, and then spawn a tokio thread, for the input handler
fn handler_init(
app_data: &Arc<Mutex<AppData>>,
docker_sx: &Sender<DockerMessage>,
gui_state: &Arc<Mutex<GuiState>>,
input_rx: Receiver<InputMessages>,
is_running: &Arc<AtomicBool>,
) {
tokio::spawn(input_handler::InputHandler::start(
Arc::clone(app_data),
docker_sx.clone(),
Arc::clone(gui_state),
Arc::clone(is_running),
input_rx,
));
}
#[tokio::main]
async fn main() {
setup_tracing();
let config = config::Config::new();
let redraw = Arc::new(Rerender::new());
let app_data = Arc::new(Mutex::new(AppData::new(config.clone(), &redraw)));
let gui_state = Arc::new(Mutex::new(GuiState::new(&redraw, config.show_logs)));
let is_running = Arc::new(AtomicBool::new(true));
let (docker_tx, docker_rx) = tokio::sync::mpsc::channel(32);
docker_init(&app_data, docker_rx, docker_tx.clone(), &gui_state).await;
if config.gui {
let (input_tx, input_rx) = tokio::sync::mpsc::channel(32);
handler_init(&app_data, &docker_tx, &gui_state, input_rx, &is_running);
Ui::start(app_data, gui_state, input_tx, is_running, redraw).await;
} else {
info!("in debug mode\n");
let mut now = std::time::Instant::now();
// Debug mode for testing, less pointless now, will display some basic information
while is_running.load(Ordering::SeqCst) {
let err = app_data.lock().get_error();
if let Some(err) = err {
error!("{}", err);
process::exit(1);
}
if let Some(Ok(to_sleep)) = u128::from(config.docker_interval_ms)
.checked_sub(now.elapsed().as_millis())
.map(u64::try_from)
{
tokio::time::sleep(std::time::Duration::from_millis(to_sleep)).await;
}
let containers = app_data
.lock()
.get_container_items()
.iter()
.map(|i| format!("{i}"))
.collect::<Vec<_>>();
if !containers.is_empty() {
for item in containers {
info!("{item}");
}
println!();
}
now = std::time::Instant::now();
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::{str::FromStr, sync::Arc};
use bollard::service::{ContainerSummary, PortSummary};
use crate::{
app_data::{
AppData, ContainerId, ContainerItem, ContainerPorts, ContainerStatus, Filter,
RunningState, State, StatefulList,
},
config::{AppColors, Config, Keymap},
ui::Rerender,
};
/// Default test config, has timestamps turned off
pub fn gen_config() -> Config {
Config {
app_colors: AppColors::new(),
color_logs: false,
dir_save: None,
dir_config: None,
docker_interval_ms: 1000,
gui: true,
host: None,
in_container: false,
keymap: Keymap::new(),
log_search_case_sensitive: true,
raw_logs: false,
show_logs: true,
show_self: false,
show_std_err: false,
show_timestamp: false,
timestamp_format: "HH:MM:SS.NNNNN dd-mm-yyyy".to_owned(),
timezone: None,
use_cli: false,
}
}
pub fn gen_item(id: &ContainerId, index: usize) -> ContainerItem {
ContainerItem::new(
u64::try_from(index).unwrap(),
id.clone(),
format!("image_{index}"),
false,
format!("container_{index}"),
vec![ContainerPorts {
ip: None,
private: u16::try_from(index).unwrap_or(1) + 8000,
public: None,
}],
State::Running(RunningState::Healthy),
ContainerStatus::from(format!("Up {index} hour")),
)
}
pub fn gen_appdata(containers: &[ContainerItem]) -> AppData {
AppData {
containers: StatefulList::new(containers.to_vec()),
hidden_containers: vec![],
current_sorted_id: vec![],
inspect_data: None,
error: None,
sorted_by: None,
rerender: Arc::new(Rerender::new()),
filter: Filter::new(),
config: gen_config(),
}
}
pub fn gen_containers() -> (Vec<ContainerId>, Vec<ContainerItem>) {
let ids = (1..=3)
.map(|i| ContainerId::from(format!("{i}").as_str()))
.collect::<Vec<_>>();
let containers = ids
.iter()
.enumerate()
.map(|(index, id)| gen_item(id, index + 1))
.collect::<Vec<_>>();
(ids, containers)
}
pub fn gen_container_summary(index: usize, state: &str) -> ContainerSummary {
ContainerSummary {
image_manifest_descriptor: None,
health: None,
id: Some(format!("{index}")),
names: Some(vec![format!("container_{}", index)]),
image: Some(format!("image_{index}")),
image_id: Some(format!("{index}")),
command: None,
created: Some(i64::try_from(index).unwrap()),
ports: Some(vec![PortSummary {
ip: None,
private_port: u16::try_from(index).unwrap_or(1) + 8000,
public_port: None,
typ: None,
}]),
size_rw: None,
size_root_fs: None,
labels: None,
state: Some(bollard::secret::ContainerSummaryStateEnum::from_str(state).unwrap()),
status: Some(format!("Up {index} hour")),
host_config: None,
network_settings: None,
mounts: None,
}
}
}
-157
View File
@@ -1,157 +0,0 @@
pub mod log_sanitizer {
use cansi::{Color as CansiColor, Intensity, v3::categorise_text};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
/// Attempt to colorize the given string to ratatui standards
pub fn colorize_logs<'a>(input: &str) -> Vec<Line<'a>> {
vec![Line::from(
categorise_text(input)
.iter()
.map(|i| {
let mut style = Style::default()
.bg(color_ansi_to_tui(i.bg.unwrap_or(CansiColor::Black)))
.fg(color_ansi_to_tui(i.fg.unwrap_or(CansiColor::White)));
if i.blink.is_some() {
style = style.add_modifier(Modifier::SLOW_BLINK);
}
if i.underline.is_some() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if i.reversed.is_some() {
style = style.add_modifier(Modifier::REVERSED);
}
if i.intensity == Some(Intensity::Bold) {
style = style.add_modifier(Modifier::BOLD);
}
if i.hidden.is_some() {
style = style.add_modifier(Modifier::HIDDEN);
}
if i.strikethrough.is_some() {
style = 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 ratatui Lines
pub fn remove_ansi<'a>(input: &str) -> Vec<Line<'a>> {
vec![Line::from(
categorise_text(input)
.into_iter()
.map(|i| i.text)
.collect::<String>()
.trim()
.to_owned(),
)]
}
/// create ratatui Lines that exactly match the given strings
pub fn raw<'a>(input: &str) -> Vec<Line<'a>> {
vec![Line::from(input.escape_debug().collect::<String>())]
}
/// Change from ansi to tui colors
const fn color_ansi_to_tui(color: CansiColor) -> Color {
match color {
CansiColor::Black | CansiColor::BrightBlack => 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 | CansiColor::BrightWhite => Color::Gray,
CansiColor::BrightRed => Color::LightRed,
CansiColor::BrightGreen => Color::LightGreen,
CansiColor::BrightYellow => Color::LightYellow,
CansiColor::BrightBlue => Color::LightBlue,
CansiColor::BrightMagenta => Color::LightMagenta,
CansiColor::BrightCyan => Color::LightCyan,
}
}
}
#[cfg(test)]
mod tests {
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use super::log_sanitizer;
// This spells out "oxker", with each char having a foreground and background colour
const INPUT: &str = "\x1b[31;47mo\x1b[32;40mx\x1b[33;41mk\x1b[34;42me\x1b[35;43mr\x1b[0m";
#[test]
/// Return test raw, as in show escape codes
fn test_color_match_raw() {
let result = log_sanitizer::raw(INPUT);
let expected = vec![Line {
spans: [Span {
content: std::borrow::Cow::Borrowed(
"\\u{1b}[31;47mo\\u{1b}[32;40mx\\u{1b}[33;41mk\\u{1b}[34;42me\\u{1b}[35;43mr\\u{1b}[0m",
),
style: Style::default(),
}]
.to_vec(),
alignment: None,
style: Style::default(),
}];
assert_eq!(result, expected);
}
#[test]
/// Use the escape codes to colorize the text
fn test_color_match_colorize() {
let result = log_sanitizer::colorize_logs(INPUT);
let expected = vec![Line {
spans: vec![
Span {
content: std::borrow::Cow::Borrowed("o"),
style: Style::default().fg(Color::Red).bg(Color::Gray),
},
Span {
content: std::borrow::Cow::Borrowed("x"),
style: Style::default().fg(Color::Green).bg(Color::Black),
},
Span {
content: std::borrow::Cow::Borrowed("k"),
style: Style::default().fg(Color::Yellow).bg(Color::Red),
},
Span {
content: std::borrow::Cow::Borrowed("e"),
style: Style::default().fg(Color::Blue).bg(Color::Green),
},
Span {
content: std::borrow::Cow::Borrowed("r"),
style: Style::default().fg(Color::Magenta).bg(Color::Yellow),
},
],
alignment: None,
style: Style::default(),
}];
assert_eq!(result, expected);
}
#[test]
/// Remove all escape ansi codes from given input
fn test_color_match_remove_ansi() {
let result = log_sanitizer::remove_ansi(INPUT);
let expected = vec![Line {
spans: vec![Span {
content: std::borrow::Cow::Borrowed("oxker"),
style: Style::default(),
}],
style: Style::default(),
alignment: None,
}];
assert_eq!(result, expected);
}
}
-700
View File
@@ -1,700 +0,0 @@
use std::fmt::Display;
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Color, Modifier, Style, Stylize},
symbols::{self, Marker},
text::{Line, Span},
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType},
};
use super::FrameData;
use crate::{
app_data::{State, Stats},
config::AppColors,
};
fn make_chart<'a, T: Stats + Display>(
state: State,
colors: AppColors,
dataset: Vec<Dataset<'a>>,
current_rx: &'a T,
max_rx: &'a T,
current_tx: &'a T,
max_tx: &'a T,
) -> Chart<'a> {
let gen_color = |state: &State, default: Color| {
if state.is_healthy() {
default
} else {
state.get_color(colors)
}
};
let mut labels = [
Span::raw(""),
Span::styled(
format!("{max_rx}"),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(gen_color(&state, colors.chart_bandwidth.max_rx)),
),
Span::styled(
format!("{max_tx}"),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(gen_color(&state, colors.chart_bandwidth.max_tx)),
),
Span::raw(""),
];
// Set the order of rx/tx on the y axis, based on which is the highest value
if max_rx.get_value() > max_tx.get_value() {
labels.reverse();
}
Chart::new(dataset)
.bg(colors.chart_bandwidth.background)
.block(
Block::default()
.title_alignment(Alignment::Center)
.title(Line::from(vec![
Span::styled(
format!(" rx: {current_rx}"),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(gen_color(&state, colors.chart_bandwidth.title_rx)),
),
Span::styled(
format!(" tx: {current_tx} "),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(gen_color(&state, colors.chart_bandwidth.title_tx)),
),
]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colors.chart_bandwidth.border)),
)
.x_axis(Axis::default().bounds([0.0, 60.0]))
.y_axis(
Axis::default()
.labels(labels)
.style(Style::default().fg(colors.chart_bandwidth.y_axis))
.bounds([0.0, (max_rx.get_value()).max(max_tx.get_value()) + 0.01]),
)
}
/// Draw bandwidth chart
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some(x) = fd.chart_data.as_ref() {
let mut dataset = vec![
Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_bandwidth.points_tx))
.graph_type(GraphType::Line)
.marker(Marker::Dot)
.style(Style::default().fg(colors.chart_bandwidth.points_tx))
.data(&x.tx.dataset),
];
dataset.extend(vec![
Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_bandwidth.points_rx))
.marker(Marker::Dot)
.style(Style::default().fg(colors.chart_bandwidth.points_rx))
.graph_type(GraphType::Line)
.data(&x.rx.dataset),
]);
let chart = make_chart(
x.state,
colors,
dataset,
&x.rx.current,
&x.rx.max,
&x.tx.current,
&x.tx.max,
);
f.render_widget(chart, area);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::Color;
use crate::{
app_data::{ContainerId, NetworkBandwidth, State},
config::AppColors,
ui::{
FrameData,
draw_blocks::tests::{COLOR_RX, COLOR_TX, get_result, test_setup},
},
};
const TX_DOTS: [(usize, usize); 14] = [
(1, 21),
(2, 19),
(2, 20),
(3, 18),
(3, 19),
(4, 10),
(4, 11),
(4, 17),
(4, 18),
(5, 16),
(6, 14),
(6, 15),
(7, 13),
(7, 14),
];
const RX_DOTS: [(usize, usize); 15] = [
(1, 21),
(2, 19),
(2, 20),
(3, 18),
(3, 19),
(4, 10),
(4, 11),
(4, 17),
(4, 18),
(5, 16),
(6, 16),
(6, 15),
(7, 13),
(7, 14),
(8, 13),
];
const COMBINED_DOTS_RX: [(usize, usize); 15] = [
(1, 21),
(2, 19),
(2, 20),
(3, 18),
(3, 19),
(4, 10),
(4, 11),
(4, 17),
(4, 18),
(5, 16),
(6, 15),
(6, 16),
(7, 13),
(7, 14),
(8, 13),
];
const COMBINED_DOTS_TX: [(usize, usize); 8] = [
(7, 19),
(7, 20),
(7, 21),
(8, 14),
(8, 15),
(8, 16),
(8, 17),
(8, 18),
];
#[test]
/// When status is Running, but not data, charts drawn without dots etc, colours correct
fn test_draw_blocks_charts_running_none() {
let mut setup = test_setup(40, 10, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=4 | 34..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title RX
(0, 5..=18) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// Title TX
(0, 19..=33) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// Y axis
(1..=8, 10) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// TX max
(4, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// RX max
(6, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Test with TX data
fn test_draw_blocks_charts_running_with_data_tx() {
let mut setup = test_setup(40, 10, true, true);
let mut tx = NetworkBandwidth::new();
for i in 0..=20 {
tx.push(1000 * i * (10 + 5 * i));
}
if let Some(item) = setup
.app_data
.lock()
.get_container_by_id(&ContainerId::from("1"))
{
item.tx = tx;
}
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=3 | 35..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title RX
(0, 4..=17) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// Title TX
(0, 18..=34) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// Y axis
(1..=8, 12) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// TX max
(4, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// RX max
(6, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// TX dots
x if TX_DOTS.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Test with RX data
fn test_draw_blocks_charts_running_with_data_rx() {
let mut setup = test_setup(40, 10, true, true);
let mut rx = NetworkBandwidth::new();
for i in 0..=20 {
rx.push(2000 * i * (10 + 7 * i));
}
if let Some(item) = setup
.app_data
.lock()
.get_container_by_id(&ContainerId::from("1"))
{
item.rx = rx;
}
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=3 | 35..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title RX
(0, 4..=19) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// Title TX
(0, 20..=34) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// Y axis
(1..=8, 12) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// RX max
(4, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// TX max
(6, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// RX dots
x if RX_DOTS.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Test with RX & TX data
fn test_draw_blocks_charts_running_with_data_tx_and_rx() {
let mut setup = test_setup(40, 10, true, true);
let mut rx = NetworkBandwidth::new();
let mut tx = NetworkBandwidth::new();
for i in 0..=20 {
rx.push(2000 * i * (10 + 7 * i));
tx.push(200 * i * (10 + 7 * i));
}
if let Some(item) = setup
.app_data
.lock()
.get_container_by_id(&ContainerId::from("1"))
{
item.rx = rx;
item.tx = tx;
}
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=3 | 36..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title RX
(0, 4..=19) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// Title TX
(0, 20..=35) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// Y axis
(1..=8, 12) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// RX max
(4, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
// TX max
(6, 1..=10) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// TX dots
x if COMBINED_DOTS_TX.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_TX);
}
// RX dots
x if COMBINED_DOTS_RX.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, COLOR_RX);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Whens status paused, some text is now Yellow
fn test_draw_blocks_charts_paused() {
let mut setup = test_setup(40, 10, true, true);
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=4 | 34..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title & y-axis max
(0, 5..=33) | (4 | 6, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Yellow);
}
// Y axis
(1..=8, 10) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Whens status dead, some text is now red
fn test_draw_blocks_charts_dead() {
let mut setup = test_setup(40, 10, true, true);
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Border first row only
(0, 0..=4 | 34..=39) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
// Title & y-axis max
(0, 5..=33) | (4 | 6, 1..=9) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Red);
}
// Y axis
(1..=8, 10) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Custom colours correctly applied to each part of the charts
fn test_draw_blocks_charts_custom_colors() {
let mut colors = AppColors::new();
colors.chart_bandwidth.background = Color::White;
colors.chart_bandwidth.border = Color::Red;
colors.chart_bandwidth.max_rx = Color::Green;
colors.chart_bandwidth.max_tx = Color::Magenta;
colors.chart_bandwidth.title_rx = Color::LightGreen;
colors.chart_bandwidth.title_tx = Color::LightRed;
colors.chart_bandwidth.points_rx = Color::Black;
colors.chart_bandwidth.points_tx = Color::Blue;
colors.chart_bandwidth.y_axis = Color::Yellow;
let mut setup = test_setup(40, 10, true, true);
let mut rx = NetworkBandwidth::new();
let mut tx = NetworkBandwidth::new();
for i in 0..=20 {
rx.push(2000 * i * (10 + 7 * i));
tx.push(200 * i * (10 + 7 * i));
}
if let Some(item) = setup
.app_data
.lock()
.get_container_by_id(&ContainerId::from("1"))
{
item.rx = rx;
item.tx = tx;
}
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
// border
(9, _) | (1..=9, 0 | 39) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
}
// Border first row only
(0, 0..=3 | 36..=39) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
}
// Title RX
(0, 4..=19) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::LightGreen);
}
// Title TX
(0, 20..=35) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::LightRed);
}
// Y axis
(1..=8, 12) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Yellow);
}
// RX max
(4, 1..=11) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Green);
}
// TX max
(6, 1..=10) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Magenta);
}
// TX dots
x if COMBINED_DOTS_TX.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Blue);
}
// RX dots
x if COMBINED_DOTS_RX.contains(&(row_index, result_cell_index)) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
-476
View File
@@ -1,476 +0,0 @@
use std::fmt::Display;
use ratatui::{
Frame,
layout::{Alignment, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::Span,
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType},
};
use super::{CONSTRAINT_50_50, FrameData};
use crate::{
app_data::{State, Stats},
config::AppColors,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChartVariant {
Cpu,
Memory,
}
impl ChartVariant {
const fn name(self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Memory => "memory",
}
}
const fn get_title_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.title,
Self::Memory => colors.chart_memory.title,
}
} else {
state.get_color(colors)
}
}
const fn get_bg_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.background,
Self::Memory => colors.chart_memory.background,
}
}
const fn get_border_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.border,
Self::Memory => colors.chart_memory.border,
}
}
const fn get_y_axis_color(self, colors: AppColors) -> Color {
match self {
Self::Cpu => colors.chart_cpu.y_axis,
Self::Memory => colors.chart_memory.y_axis,
}
}
const fn get_max_color(self, colors: AppColors, state: State) -> Color {
if state.is_healthy() {
match self {
Self::Cpu => colors.chart_cpu.max,
Self::Memory => colors.chart_memory.max,
}
} else {
state.get_color(colors)
}
}
}
/// Create charts
fn make_chart<'a, T: Stats + Display>(
chart_variant: ChartVariant,
colors: AppColors,
current: &'a T,
dataset: Vec<Dataset<'a>>,
max: &'a T,
state: State,
) -> Chart<'a> {
let max_color = chart_variant.get_max_color(colors, state);
Chart::new(dataset)
.bg(chart_variant.get_bg_color(colors))
.block(
Block::default()
.style(Style::default().bg(chart_variant.get_bg_color(colors)))
.title_alignment(Alignment::Center)
.title(Span::styled(
format!(" {} {current} ", chart_variant.name()),
Style::default()
.fg(chart_variant.get_title_color(colors, state))
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(chart_variant.get_border_color(colors))),
)
.x_axis(Axis::default().bounds([0.00, 60.0]))
.y_axis(
Axis::default()
.labels(vec![
Span::styled("", Style::default().fg(max_color)),
Span::styled(
format!("{max}"),
Style::default().add_modifier(Modifier::BOLD).fg(max_color),
),
])
.style(Style::new().fg(chart_variant.get_y_axis_color(colors)))
// Add 0.01, so that max point is always visible?
.bounds([0.0, max.get_value() + 0.01]),
)
}
/// Draw the cpu + mem charts
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some(x) = fd.chart_data.as_ref() {
let area = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_50_50)
.split(area);
let cpu_dataset = vec![
Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_cpu.points))
.graph_type(GraphType::Line)
.data(&x.cpu.dataset),
];
let mem_dataset = vec![
Dataset::default()
.marker(symbols::Marker::Dot)
.style(Style::default().fg(colors.chart_memory.points))
.graph_type(GraphType::Line)
.data(&x.memory.dataset),
];
// let cpu_stats = CpuStats::new(cpu.0.last().map_or(0.00, |f| f.1));
// #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
// let mem_stats = ByteStats::new(mem.0.last().map_or(0, |f| f.1 as u64));
let cpu_chart = make_chart(
ChartVariant::Cpu,
colors,
&x.cpu.current,
cpu_dataset,
&x.cpu.max,
x.state,
);
let mem_chart = make_chart(
ChartVariant::Memory,
colors,
&x.memory.current,
mem_dataset,
&x.memory.max,
x.state,
);
f.render_widget(cpu_chart, area[0]);
f.render_widget(mem_chart, area[1]);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::State,
config::AppColors,
ui::{
FrameData,
draw_blocks::tests::{COLOR_ORANGE, get_result, insert_all_chart_data, test_setup},
},
};
/// CPU and Memory charts used in multiple tests, based on data from above insert_chart_data()
const _EXPECTED: [&str; 10] = [
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮",
"│10.00%│ • ││100.00 kB│ •• │",
"│ │ •• ││ │ •• │",
"│ │ ••• ││ │ • • │",
"│ │ • • ││ │ • • │",
"│ │ • •• ││ │•• •• │",
"│ │• • ││ │• • │",
"│ │• • ││ │• • │",
"│ │ ││ │ │",
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯",
];
// co-ordinates of the dots from the cpu chart
const CPU_XY: [(usize, usize); 16] = [
(1, 13),
(2, 12),
(2, 13),
(3, 11),
(3, 13),
(4, 11),
(4, 13),
(5, 10),
(5, 13),
(6, 9),
(6, 13),
(6, 14),
(7, 8),
(7, 9),
(7, 13),
(7, 14),
];
// co-ordinates of the dots from the memory chart
const MEM_XY: [(usize, usize); 14] = [
(1, 55),
(2, 54),
(2, 55),
(3, 54),
(3, 55),
(4, 53),
(4, 55),
(5, 52),
(5, 53),
(5, 56),
(6, 52),
(6, 56),
(7, 51),
(7, 56),
];
#[test]
/// When status is Running, but not data, charts drawn without dots etc, colours correct
fn test_draw_blocks_charts_running_none() {
let mut setup = test_setup(80, 10, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 14..=25 | 52..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=47) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(2..=8, 1..=6 | 8..=38 | 49..=78 | 41..=47) | (1, 8..=38 | 49..=78) => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When status is Running, charts correctly drawn
fn test_draw_blocks_charts_running_some() {
let mut setup = test_setup(80, 10, true, true);
insert_all_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, COLOR_ORANGE);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Whens status paused, some text is now Yellow
fn test_draw_blocks_charts_paused() {
let mut setup = test_setup(80, 10, true, true);
insert_all_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
//
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// When dead, text is red
fn test_draw_blocks_charts_dead() {
let mut setup = test_setup(80, 10, true, true);
insert_all_chart_data(&setup);
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 14..=25 | 51..=67) | (1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
xy if CPU_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Magenta);
assert!(result_cell.modifier.is_empty());
}
xy if MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Cyan);
assert!(result_cell.modifier.is_empty());
}
(0 | 9, 0..=80) | (1..=9, 0 | 7 | 39 | 40 | 50 | 79) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Custom colos correctly applied to each part of the charts
fn test_draw_blocks_charts_custom_colors() {
let mut colors = AppColors::new();
colors.chart_cpu.background = Color::White;
colors.chart_cpu.border = Color::Red;
colors.chart_cpu.title = Color::Green;
colors.chart_cpu.max = Color::Magenta;
colors.chart_cpu.points = Color::Black;
colors.chart_cpu.y_axis = Color::Blue;
colors.chart_memory.background = Color::White;
colors.chart_memory.border = Color::Red;
colors.chart_memory.title = Color::Green;
colors.chart_memory.max = Color::Magenta;
colors.chart_memory.points = Color::Black;
colors.chart_memory.y_axis = Color::Blue;
let mut setup = test_setup(80, 10, true, true);
insert_all_chart_data(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// border
(0, 0..=13 | 26..=50 | 68..=79) | (9, _) | (1..=8, 0 | 39 | 40 | 79) => {
assert_eq!(result_cell.fg, Color::Red);
}
// title
(0, 14..=25 | 51..=67) => {
assert_eq!(result_cell.fg, Color::Green);
}
// max label
(1, 1..=6 | 41..=49) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// data points
xy if CPU_XY.contains(&xy) | MEM_XY.contains(&xy) => {
assert_eq!(result_cell.fg, Color::Black);
}
// y axis
(1..=8, 7 | 50) => {
assert_eq!(result_cell.fg, Color::Blue);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
-413
View File
@@ -1,413 +0,0 @@
use std::sync::Arc;
use super::SELECT_ARROW;
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel},
};
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
};
use super::generate_block;
/// Draw the command panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let block = generate_block(area, colors, fd, gui_state, SelectablePanel::Commands)
.bg(colors.commands.background);
let items = app_data.lock().get_control_items().map_or(vec![], |i| {
i.iter()
.map(|c| {
let lines = Line::from(vec![Span::styled(
c.to_string(),
Style::default().fg(c.get_color(colors)),
)]);
ListItem::new(lines)
})
.collect::<Vec<_>>()
});
if let Some(i) = app_data.lock().get_control_state() {
let items = List::new(items)
.block(block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol(SELECT_ARROW);
f.render_stateful_widget(items, area, i);
} else {
let paragraph = Paragraph::new("").block(block).alignment(Alignment::Center);
f.render_widget(paragraph, area);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::ScrollDirection,
config::AppColors,
tests::gen_container_summary,
ui::{
FrameData,
draw_blocks::tests::{BORDER_CHARS, get_result, test_setup},
},
};
// cusomt border colors
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
/// In this case, no commands are drawn
fn test_draw_blocks_commands_none() {
let mut setup = test_setup(12, 6, false, false);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
/// In this test, container is running
fn test_draw_blocks_commands_some() {
let mut setup = test_setup(12, 6, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// Borders & delete
(0 | 5, _) | (1..=4, 0 | 11) | (4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Test that when DockerCommands are available, they are drawn correctly, dependant on container state
/// In this test, container is paused
fn test_draw_blocks_commands_some_paused() {
let mut setup = test_setup(12, 6, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
// Set the container state to paused
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup
.app_data
.lock()
.docker_controls_scroll(&ScrollDirection::Down);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
// resume
(1, 3..=8) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Red);
}
// delete
(0 | 5, _) | (1..=4, 0 | 11) | (3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// When control panel is selected, the border is blue, if not then white, selected text is highlighted
fn test_draw_blocks_commands_panel_selected_color() {
let mut setup = test_setup(12, 6, true, true);
let colors = setup.app_data.lock().config.app_colors;
// Unselected, has a grey border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for result_cell in result_row {
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
// Control panel now selected, should have a blue border
setup
.gui_state
.lock()
.selectable_panel_next(&setup.app_data);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
if row_index == 0
|| row_index == 5
|| result_cell_index == 0
|| result_cell_index == 11
{
assert_eq!(result_cell.fg, Color::LightCyan);
}
if row_index == 1 && result_cell_index > 0 && result_cell_index < 11 {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Custom colors are rendered correctly
fn test_draw_blocks_commands_custom_colors_running() {
let mut setup = test_setup(12, 6, true, true);
let mut colors = AppColors::new();
colors.commands.background = Color::White;
colors.commands.pause = Color::Black;
colors.commands.restart = Color::Green;
colors.commands.stop = Color::Blue;
colors.commands.delete = Color::Magenta;
colors.commands.resume = Color::Yellow;
colors.commands.start = Color::Cyan;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// pause
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Black);
}
// restart
(2, 3..=9) => {
assert_eq!(result_cell.fg, Color::Green);
}
// stop
(3, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(4, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
}
#[test]
/// Custom colors are rendered correctly
fn test_draw_blocks_commands_custom_colors_paused() {
let mut setup = test_setup(12, 6, true, true);
let mut colors = AppColors::new();
colors.commands.background = Color::White;
colors.commands.pause = Color::Black;
colors.commands.restart = Color::Green;
colors.commands.stop = Color::Blue;
colors.commands.delete = Color::Magenta;
colors.commands.resume = Color::Yellow;
colors.commands.start = Color::Cyan;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
// Set the controls state
setup
.app_data
.lock()
.update_containers(vec![gen_container_summary(1, "paused")]);
setup
.app_data
.lock()
.docker_controls_scroll(&ScrollDirection::Down);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::White);
match (row_index, result_cell_index) {
// resume
(1, 3..=7) => {
assert_eq!(result_cell.fg, Color::Yellow);
}
// stop
(2, 3..=6) => {
assert_eq!(result_cell.fg, Color::Blue);
}
// delete
(3, 3..=8) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
_ => (),
}
}
}
}
}
File diff suppressed because it is too large Load Diff
-329
View File
@@ -1,329 +0,0 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::{Alignment, Direction, Layout},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use super::{CONSTRAINT_BUTTONS, CONSTRAINT_POPUP};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::{
DeleteButton, GuiState,
gui_state::{BoxLocation, Region},
},
};
use super::popup;
/// Draw the delete confirm box in the centre of the screen
/// take in container id and container name here?
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
name: &ContainerName,
) {
let block = Block::default()
.title(" Confirm Delete ")
.border_type(BorderType::Rounded)
.style(
Style::default()
.bg(colors.popup_delete.background)
.fg(colors.popup_delete.text),
)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let confirm = Line::from(vec![
Span::from("Are you sure you want to delete container: "),
Span::styled(
name.get(),
Style::default()
.fg(colors.popup_delete.text_highlight)
.bg(colors.popup_delete.background)
.add_modifier(Modifier::BOLD),
),
]);
let yes_text = if keymap.delete_confirm == Keymap::new().delete_confirm {
"( y ) yes".to_owned()
} else if let Some(secondary) = keymap.delete_confirm.1 {
format!("( {} | {} ) yes", keymap.delete_confirm.0, secondary)
} else {
format!("( {} ) yes", keymap.delete_confirm.0)
};
let no_text = if keymap.delete_deny == Keymap::new().delete_deny {
"( n ) no".to_owned()
} else if let Some(secondary) = keymap.delete_deny.1 {
format!("( {} | {} ) no", keymap.delete_deny.0, secondary)
} else {
format!("( {} ) no", keymap.delete_deny.0)
};
// Find the maximum line width & height, and add some padding
let max_line_width = u16::try_from(confirm.width()).unwrap_or(64) + 12;
let lines = 8;
let confirm_para = Paragraph::new(confirm).alignment(Alignment::Center);
let button_block = || {
Block::default()
.border_type(BorderType::Rounded)
.borders(Borders::ALL)
.style(Style::default().bg(colors.popup_delete.background))
};
let yes_para = Paragraph::new(yes_text)
.alignment(Alignment::Center)
.block(button_block());
let no_para = Paragraph::new(no_text)
.alignment(Alignment::Center)
.block(button_block());
let area = popup::draw(
lines,
max_line_width.into(),
f.area(),
BoxLocation::MiddleCentre,
);
let split_popup = Layout::default()
.direction(Direction::Vertical)
.constraints(CONSTRAINT_POPUP)
.split(area);
let split_buttons = Layout::default()
.direction(Direction::Horizontal)
.constraints(CONSTRAINT_BUTTONS)
.split(split_popup[3]);
let no_area = split_buttons[1];
let yes_area = split_buttons[3];
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(confirm_para, split_popup[1]);
f.render_widget(no_para, no_area);
f.render_widget(yes_para, yes_area);
// Insert button areas into region map, so can interact with them on click
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Cancel), no_area);
gui_state
.lock()
.update_region_map(Region::Delete(DeleteButton::Confirm), yes_area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::KeyCode;
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::ContainerName,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{get_result, test_setup},
};
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete() {
let mut setup = test_setup(82, 10, true, true);
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Delete container popup is drawn correctly
fn test_draw_blocks_delete_long_name() {
let mut setup = test_setup(106, 10, true, true);
let name = ContainerName::from("container_1_container_1_container_1");
setup.app_data.lock().containers.items[0].name = name.clone();
let colors = setup.app_data.lock().config.app_colors;
let keymap = &setup.app_data.lock().config.keymap;
setup
.terminal
.draw(|f| {
super::draw(colors, f, &setup.gui_state, keymap, &name);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 98..=106) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=91) => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom colors applied correctly to delete popup
fn test_draw_blocks_delete_custom_colors() {
let mut setup = test_setup(82, 10, true, true);
let mut colors = AppColors::new();
colors.popup_delete.background = Color::Black;
colors.popup_delete.text = Color::Yellow;
colors.popup_delete.text_highlight = Color::Green;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&Keymap::new(),
&ContainerName::from("container_1"),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 9, _) | (1..=8, 0..=7 | 74..=81) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
(3, 57..=67) => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(result_cell.fg, Color::Yellow);
}
}
}
}
}
#[test]
/// Custom keymap, with multiple definitions for each button, applied correctly to delete popup
fn test_draw_blocks_delete_custom_keymap_one_definition() {
let mut setup = test_setup(82, 10, true, true);
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, None);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom keymap, with multiple definitions for each button, applied correctly to delete popup
fn test_draw_blocks_delete_custom_keymap_two_definition() {
let mut setup = test_setup(82, 10, true, true);
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), Some(KeyCode::Char('L')));
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom keymap, with multiple definitions for each button, applied correctly to delete popup
fn test_draw_blocks_delete_custom_keymap_one_two_definition() {
let mut setup = test_setup(82, 10, true, true);
let mut keymap = Keymap::new();
keymap.delete_confirm = (KeyCode::F(10), None);
keymap.delete_deny = (KeyCode::End, Some(KeyCode::Up));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
f,
&setup.gui_state,
&keymap,
&ContainerName::from("container_1"),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
}
-333
View File
@@ -1,333 +0,0 @@
use ratatui::{
Frame,
layout::Alignment,
style::Style,
widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use super::{NAME, VERSION, max_line_width};
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::gui_state::BoxLocation,
};
use super::popup;
const SUFFIX_CLEAR: &str = "clear error";
const SUFFIX_QUIT: &str = "quit oxker";
/// Draw an error popup over whole screen
pub fn draw(
colors: AppColors,
error: &AppError,
f: &mut Frame,
host: Option<String>,
keymap: &Keymap,
seconds: Option<u8>,
) {
let block = Block::default()
.title(" Error ")
.border_type(BorderType::Rounded)
.title_alignment(Alignment::Center)
.borders(Borders::ALL);
let mut text = format!("\n{error}");
if error == &AppError::DockerConnect {
let s = if let Some(host) = host {
format!(" @ \"{host}\"")
} else {
String::new()
};
text.push_str(&format!(
"{}\n\n {}::v{} closing in {:02} seconds",
s,
NAME,
VERSION,
seconds.unwrap_or(5),
))
} else {
let clear_text = if keymap.clear == Keymap::new().clear {
format!("( {} ) {SUFFIX_CLEAR}", keymap.clear.0)
} else if let Some(secondary) = keymap.clear.1 {
format!(" ( {} | {secondary} ) {SUFFIX_CLEAR}", keymap.clear.0)
} else {
format!(" ( {} ) {SUFFIX_CLEAR}", keymap.clear.0)
};
text.push_str(&format!("\n\n{clear_text}"));
}
let quit_text = if keymap.quit == Keymap::new().quit {
format!("( {} ) {SUFFIX_QUIT}", keymap.quit.0)
} else if let Some(secondary) = keymap.quit.1 {
format!(" ( {} | {secondary} ) {SUFFIX_QUIT}", keymap.quit.0)
} else {
format!(" ( {} ) {SUFFIX_QUIT}", keymap.quit.0)
};
text.push_str(&format!("\n\n{quit_text}"));
// Find the maximum line width & height
let padded_width = max_line_width(&text) + 8;
let line_count = text.lines().count();
let padded_height = if line_count % 2 == 0 {
line_count + 3
} else {
line_count + 2
};
let paragraph = Paragraph::new(text)
.style(
Style::default()
.bg(colors.popup_error.background)
.fg(colors.popup_error.text),
)
.block(block)
.alignment(Alignment::Center);
let area = popup::draw(
padded_height,
padded_width,
f.area(),
BoxLocation::MiddleCentre,
);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::{
app_error::AppError,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{get_result, test_setup},
};
use crossterm::event::KeyCode;
use insta::assert_snapshot;
use ratatui::style::Color;
#[test]
/// Test that the error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_error_docker_connect_error() {
let mut setup = test_setup(50, 11, true, true);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerConnect,
f,
None,
&Keymap::new(),
Some(4),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 10, _) | (_, 0 | 49) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Test that the error popup is centered, red background, white border, white text, and displays the correct text with the custom docker host address
fn test_draw_blocks_error_docker_connect_error_custom_host() {
let mut setup = test_setup(60, 11, true, true);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerConnect,
f,
Some("/test/host.sock".to_owned()),
&Keymap::new(),
Some(4),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 10, _) | (_, 0 | 59) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Test that the clearable error popup is centered, red background, white border, white text, and displays the correct text
fn test_draw_blocks_error_clearable_error() {
let mut setup = test_setup(39, 11, true, true);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerExec,
f,
None,
&Keymap::new(),
Some(4),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::White);
}
}
}
}
}
#[test]
/// Custom colors applied to the error popup correctly
fn test_draw_blocks_error_custom_colors() {
let mut setup = test_setup(39, 11, true, true);
let mut colors = AppColors::new();
colors.popup_error.background = Color::Yellow;
colors.popup_error.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(
colors,
&AppError::DockerExec,
f,
None,
&Keymap::new(),
Some(4),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 10, _) | (1..=9, 0 | 38) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
_ => {
assert_eq!(result_cell.bg, Color::Yellow);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
}
}
#[test]
/// Custom keymap applied correctly
fn test_draw_blocks_error_custom_keymap() {
let mut setup = test_setup(39, 11, true, true);
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, None);
keymap.quit = (KeyCode::F(4), None);
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerExec,
f,
None,
&keymap,
None,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom keymap applied with two definitions for each option
fn test_draw_blocks_error_custom_keymap_two_definitions() {
let mut setup = test_setup(39, 11, true, true);
let mut keymap = Keymap::new();
keymap.clear = (KeyCode::BackTab, Some(KeyCode::Char('m')));
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerExec,
f,
None,
&keymap,
None,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom keymap applied correctly, with 1 definition for the first option, and 2 definitions for the other
fn test_draw_blocks_error_custom_keymap_one_two_definitions() {
let mut setup = test_setup(39, 11, true, true);
let mut keymap = Keymap::new();
keymap.quit = (KeyCode::F(4), Some(KeyCode::End));
setup
.terminal
.draw(|f| {
super::draw(
AppColors::new(),
&AppError::DockerExec,
f,
None,
&keymap,
None,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
}
-304
View File
@@ -1,304 +0,0 @@
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style, Stylize},
text::{Line, Span},
};
use crate::{
app_data::FilterBy,
config::AppColors,
ui::{
FrameData,
draw_blocks::{LEFT_ARROW, RIGHT_ARROW},
},
};
/// Create the filter_by by spans, coloured dependant on which one is selected
fn filter_by_spans(colors: AppColors, fd: &'_ FrameData) -> [Span<'_>; 4] {
let selected = Style::default()
.bg(colors.filter.selected_filter_background)
.fg(colors.filter.selected_filter_text);
let not_selected = Style::default()
.bg(colors.filter.background)
.fg(colors.filter.text);
let name = [" Name ", " Image ", " Status ", " All "];
let mut filter_spans = [
Span::styled(name[0], not_selected),
Span::styled(name[1], not_selected),
Span::styled(name[2], not_selected),
Span::styled(name[3], not_selected),
];
match fd.filter_by {
FilterBy::Name => filter_spans[0] = Span::styled(name[0], selected),
FilterBy::Image => filter_spans[1] = Span::styled(name[1], selected),
FilterBy::Status => filter_spans[2] = Span::styled(name[2], selected),
FilterBy::All => filter_spans[3] = Span::styled(name[3], selected),
}
filter_spans
}
/// Draw the filter bar
pub fn draw(area: Rect, colors: AppColors, frame: &mut Frame, fd: &FrameData) {
let style_but = Style::default()
.fg(colors.filter.selected_filter_text)
.bg(colors.filter.highlight);
let style_desc = Style::default()
.fg(colors.filter.text)
.bg(colors.filter.background);
let mut line = vec![
Span::styled(" Esc ", style_but),
Span::styled(" clear ", style_desc),
Span::styled(format!(" {LEFT_ARROW} by {RIGHT_ARROW} "), style_but),
Span::from(" "),
];
line.extend_from_slice(&filter_by_spans(colors, fd));
line.extend_from_slice(&[
Span::styled(
" filter term: ",
Style::default()
.fg(colors.filter.highlight)
.add_modifier(Modifier::BOLD),
),
Span::styled(
fd.filter_term
.as_ref()
.map_or(String::new(), std::clone::Clone::clone),
Style::default().fg(colors.filter.text),
),
]);
frame.render_widget(Line::from(line).bg(colors.filter.background), area);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
config::AppColors,
ui::{
FrameData,
draw_blocks::tests::{get_result, test_setup},
},
};
#[test]
/// Filter row is drawn correctly & colors are correct
/// Colours change when filter_by option is changed
fn test_draw_blocks_filter_row() {
let mut setup = test_setup(140, 1, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &setup.fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27..=46 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=60 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Colours change when filter_by option is changed
fn test_draw_blocks_filter_row_text() {
let mut setup = test_setup(140, 1, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &setup.fd);
})
.unwrap();
// Test when char added to search term
setup.app_data.lock().filter_term_push('c');
setup.app_data.lock().filter_term_push('d');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27..=46 | 61..=62 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=60 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Colours change when filter_by option is changed
fn test_draw_blocks_filter_row_filter_by() {
let mut setup = test_setup(140, 1, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup.app_data.lock().filter_by_next();
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &setup.fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27..=46 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Gray);
assert_eq!(result_cell.fg, Color::Black);
}
47..=60 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Make sure custom colors are applied
fn test_draw_blocks_filter_row_custom_colors() {
let mut setup = test_setup(140, 1, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup.app_data.lock().filter_term_push('c');
setup.app_data.lock().filter_term_push('d');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let mut colors = AppColors::new();
colors.filter.background = Color::White;
colors.filter.highlight = Color::Blue;
colors.filter.selected_filter_background = Color::Red;
colors.filter.selected_filter_text = Color::Yellow;
colors.filter.text = Color::Magenta;
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 12..=19 => {
assert_eq!(result_cell.bg, Color::Blue);
assert_eq!(result_cell.fg, Color::Yellow);
}
5..=11 | 27..=46 | 61..=62 => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Magenta);
}
21..=26 => {
assert_eq!(result_cell.bg, Color::Red);
assert_eq!(result_cell.fg, Color::Yellow);
}
47..=60 => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Blue);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
-673
View File
@@ -1,673 +0,0 @@
use std::{rc::Rc, sync::Arc};
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Paragraph},
};
use super::{CONSTRAINT_100, MARGIN};
use crate::{
app_data::{Header, SortedOrder},
config::{AppColors, Keymap},
ui::{FrameData, GuiState, Status, gui_state::Region},
};
/// Generate a header paragraph with it's width
fn gen_header<'a>(
colors: AppColors,
fd: &FrameData,
header: Header,
width: usize,
) -> (Paragraph<'a>, u16) {
let block = gen_header_block(colors, fd, header);
let text = format!(
"{x:<width$}{MARGIN}",
x = format!("{header}{ic}", ic = block.1),
);
let count = u16::try_from(text.chars().count()).unwrap_or_default();
let status = Paragraph::new(text)
.style(gen_style(None, block.0))
.alignment(Alignment::Left);
(status, count)
}
// Generate a block for the header, if the header is currently being used to sort a column, then highlight it white
fn gen_header_block<'a>(colors: AppColors, fd: &FrameData, header: Header) -> (Color, &'a str) {
let mut color = colors.headers_bar.text;
let mut suffix = "";
if let Some((a, b)) = &fd.sorted_by
&& &header == a
{
match b {
SortedOrder::Asc => suffix = "",
SortedOrder::Desc => suffix = "",
}
color = colors.headers_bar.text_selected;
}
(color, suffix)
}
fn gen_style(bg: Option<Color>, fg: Color) -> Style {
bg.map_or_else(
|| Style::default().fg(fg),
|bg| Style::default().bg(bg).fg(fg),
)
}
/// Generate the text to display on the show help section, as can change with a custom keymap
fn gen_help_text(fd: &FrameData, keymap: &Keymap) -> String {
let suffix = if fd.status.contains(&Status::Help) {
"exit"
} else {
"show"
};
if keymap.toggle_help == Keymap::new().toggle_help {
format!("( h ) {suffix} help{MARGIN}")
} else if let Some(secondary) = keymap.toggle_help.1 {
format!(
" ( {} | {secondary} ) {suffix} help{MARGIN}",
keymap.toggle_help.0
)
} else {
format!(" ( {} ) {suffix} help{MARGIN}", keymap.toggle_help.0)
}
}
/// Draw the show/hide help section
fn draw_help(
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
help_text: String,
gui_state: &Arc<Mutex<GuiState>>,
split_bar: &Rc<[Rect]>,
) {
let help_text_color = if fd.status.contains(&Status::Help) {
colors.headers_bar.text
} else {
colors.headers_bar.text_selected
};
let help_paragraph = Paragraph::new(help_text)
.style(gen_style(None, help_text_color))
.alignment(Alignment::Right);
// If no containers, don't display the headers, could maybe do this first?
let help_index = if fd.has_containers { 2 } else { 0 };
gui_state
.lock()
.update_region_map(Region::HelpPanel, split_bar[help_index]);
f.render_widget(help_paragraph, split_bar[help_index]);
}
// Draw loading icon, or not, and a prefix with a single space
fn draw_loading_spinner(colors: AppColors, f: &mut Frame, fd: &FrameData, rect: Rect) {
let loading_paragraph = Paragraph::new(format!("{:>2}", fd.loading_icon))
.style(gen_style(None, colors.headers_bar.loading_spinner))
.alignment(Alignment::Left);
f.render_widget(loading_paragraph, rect);
}
/// Draw the sortable column headers (name/state/status etc)
fn draw_columns(
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
split_bar: &Rc<[Rect]>,
) {
if fd.has_containers {
let header_section_width = split_bar[1].width;
let mut counter = 0;
// Meta data to iterate over to create blocks with correct widths
let header_meta = [
(Header::Name, fd.columns.name.1),
(Header::State, fd.columns.state.1),
(Header::Status, fd.columns.status.1),
(Header::Cpu, fd.columns.cpu.1),
(Header::Memory, fd.columns.mem.1 + fd.columns.mem.2 + 3),
(Header::Id, fd.columns.id.1),
(Header::Image, fd.columns.image.1),
(Header::Rx, fd.columns.net_rx.1),
(Header::Tx, fd.columns.net_tx.1),
];
// Only show a header if the header cumulative header width is less than the header section width
let header_data = header_meta
.into_iter()
.filter_map(|(header, width)| {
let header_block = gen_header(colors, fd, header, usize::from(width));
counter += header_block.1;
if counter <= header_section_width {
Some((header_block.0, header, Constraint::Max(header_block.1)))
} else {
None
}
})
.collect::<Vec<_>>();
let headers_section = Layout::default()
.direction(Direction::Horizontal)
.constraints(header_data.iter().map(|i| i.2))
.split(split_bar[1]);
for (index, (paragraph, header, _)) in header_data.into_iter().enumerate() {
let rect = headers_section[index];
gui_state
.lock()
.update_region_map(Region::Header(header), rect);
f.render_widget(paragraph, rect);
}
}
}
// Draw heading bar at top of program, always visible
pub fn draw(
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
) {
let gen_style = |bg: Option<Color>, fg: Color| {
bg.map_or_else(
|| Style::default().fg(fg),
|bg| Style::default().bg(bg).fg(fg),
)
};
f.render_widget(
Block::default().style(gen_style(Some(colors.headers_bar.background), Color::Reset)),
area,
);
let help_text = gen_help_text(fd, keymap);
let help_width = help_text.chars().count();
let column_width = usize::from(area.width).saturating_sub(help_width);
let column_width = if column_width > 0 { column_width } else { 1 };
let split_bar = Layout::default()
.direction(Direction::Horizontal)
.constraints(if fd.has_containers {
vec![
Constraint::Max(4),
Constraint::Max(column_width.try_into().unwrap_or_default()),
Constraint::Max(help_width.try_into().unwrap_or_default()),
]
} else {
CONSTRAINT_100.to_vec()
})
.split(area);
draw_loading_spinner(colors, f, fd, split_bar[0]);
draw_columns(colors, f, fd, gui_state, &split_bar);
draw_help(colors, f, fd, help_text, gui_state, &split_bar);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::ops::RangeInclusive;
use crossterm::event::KeyCode;
use insta::assert_snapshot;
use ratatui::style::Color;
use uuid::Uuid;
use crate::{
app_data::{Header, SortedOrder, StatefulList},
config::{AppColors, Keymap},
ui::{
FrameData, Status,
draw_blocks::tests::{TuiTestSetup, get_result, test_setup},
},
};
#[test]
/// Heading back only has show/exit help when no containers, correctly coloured
fn test_draw_blocks_headers_no_containers_show_help() {
let mut setup = test_setup(140, 1, true, true);
setup.app_data.lock().containers = StatefulList::new(vec![]);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for result_cell in result_row {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Gray,);
}
}
}
#[test]
/// Heading back only has show/exit help when no containers, correctly coloured
fn test_draw_blocks_headers_no_containers_exit_help() {
let mut setup = test_setup(140, 1, true, true);
setup.app_data.lock().containers = StatefulList::new(vec![]);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Help);
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for result_cell in result_row {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
}
}
#[test]
/// Show all headings when containers present, colors valid
fn test_draw_blocks_headers_some_containers() {
let mut setup = test_setup(140, 1, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
112..=121 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Only show the headings that fit the reduced-in-size header section
fn test_draw_blocks_headers_some_containers_reduced_width() {
let mut setup = test_setup(80, 1, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=50 => Color::Black,
51..=61 => Color::Reset,
_ => Color::Gray,
}
);
}
}
}
#[test]
/// Show animation
fn test_draw_blocks_headers_animation() {
let mut setup = test_setup(140, 1, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
4..=111 => Color::Black,
122..=140 => Color::Gray,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom colors are applied correctly
fn test_draw_blocks_headers_custom_colors() {
let mut setup = test_setup(140, 1, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let keymap = &setup.app_data.lock().config.keymap;
let mut colors = AppColors::new();
colors.headers_bar.background = Color::Black;
colors.headers_bar.loading_spinner = Color::Green;
colors.headers_bar.text = Color::Blue;
colors.headers_bar.text_selected = Color::Yellow;
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd, &setup.gui_state, keymap);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Black);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::Green,
4..=111 => Color::Blue,
122..=140 => Color::Yellow,
_ => Color::Reset,
}
);
}
}
}
#[test]
/// Custom keymap for help panel is correctly display, with one definitions
fn test_draw_blocks_headers_custom_keymap_one_definition() {
let mut setup = test_setup(140, 1, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let mut keymap = Keymap::new();
keymap.toggle_help = (KeyCode::Char('T'), None);
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom keymap for help panel is correctly display, two definitions
fn test_draw_blocks_headers_custom_keymap_two_definitions() {
let mut setup = test_setup(140, 1, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let mut keymap = Keymap::new();
keymap.toggle_help = (KeyCode::Char('T'), Some(KeyCode::Tab));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
fn check_color(setup: &TuiTestSetup, range: RangeInclusive<usize>) {
for (_, result_row) in get_result(setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(
result_cell.fg,
match result_cell_index {
0..=3 => Color::White,
122..=139 => Color::Gray,
// given range | help section
x if range.contains(&x) => Color::Gray,
112..=121 => Color::Reset,
_ => Color::Black,
}
);
}
}
}
/// As a macro - headers test, check for asc/desc icon and colors
macro_rules! test_draw_blocks_headers_sort {
($name:ident, $header:expr, $order:expr, $color_range:expr) => {
#[test]
fn $name() {
let mut setup = test_setup(140, 1, true, true);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.sorted_by = Some(($header, $order));
setup
.terminal
.draw(|f| {
super::draw(
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
check_color(&setup, $color_range);
}
};
}
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_name_asc,
Header::Name,
SortedOrder::Asc,
1..=17
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_name_desc,
Header::Name,
SortedOrder::Desc,
1..=17
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_state_asc,
Header::State,
SortedOrder::Asc,
18..=29
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_state_desc,
Header::State,
SortedOrder::Desc,
18..=29
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_status_asc,
Header::Status,
SortedOrder::Asc,
30..=41
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_status_desc,
Header::Status,
SortedOrder::Desc,
30..=41
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_cpu_asc,
Header::Cpu,
SortedOrder::Asc,
42..=50
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_cpu_desc,
Header::Cpu,
SortedOrder::Desc,
42..=50
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_memory_asc,
Header::Memory,
SortedOrder::Asc,
51..=70
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_memory_desc,
Header::Memory,
SortedOrder::Desc,
51..=70
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_id_asc,
Header::Id,
SortedOrder::Asc,
71..=81
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_id_desc,
Header::Id,
SortedOrder::Desc,
71..=81
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_image_asc,
Header::Image,
SortedOrder::Asc,
82..=91
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_image_desc,
Header::Image,
SortedOrder::Desc,
82..=91
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_rx_asc,
Header::Rx,
SortedOrder::Asc,
92..=101
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_rx_desc,
Header::Rx,
SortedOrder::Desc,
92..=101
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_tx_asc,
Header::Tx,
SortedOrder::Asc,
102..=111
);
test_draw_blocks_headers_sort!(
test_draw_blocks_headers_sort_containers_tx_desc,
Header::Tx,
SortedOrder::Desc,
102..=111
);
}
File diff suppressed because it is too large Load Diff
-136
View File
@@ -1,136 +0,0 @@
use std::{sync::Arc, time::Instant};
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::Alignment,
style::Style,
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::{
config::AppColors,
ui::{GuiState, gui_state::BoxLocation},
};
use super::{max_line_width, popup};
/// Draw info box in one of the 9 BoxLocations
// TODO is this broken - I don't think so
pub fn draw(
colors: AppColors,
f: &mut Frame,
gui_state: &Arc<Mutex<GuiState>>,
instant: &Instant,
msg: String,
) {
let block = Block::default()
.title("")
.title_alignment(Alignment::Center)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.borders(Borders::NONE);
let max_line_width = max_line_width(&msg) + 8;
let lines = msg.lines().count() + 2;
let paragraph = Paragraph::new(msg)
.block(block)
.style(
Style::default()
.bg(colors.popup_info.background)
.fg(colors.popup_info.text),
)
.alignment(Alignment::Center);
let area = popup::draw(lines, max_line_width, f.area(), BoxLocation::BottomRight);
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
if instant.elapsed().as_millis() > 4000 {
gui_state.lock().reset_info_box();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::Color;
use crate::{
config::AppColors,
ui::draw_blocks::tests::{get_result, test_setup},
};
#[test]
/// Info box drawn in bottom right
fn test_draw_blocks_info() {
let mut setup = test_setup(45, 9, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Blue, Color::White),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
#[test]
/// Info box drawn in bottom right with custom colors applied
fn test_draw_blocks_info_custom_color() {
let mut setup = test_setup(45, 9, true, true);
let mut colors = AppColors::new();
colors.popup_info.background = Color::Red;
colors.popup_info.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(
colors,
f,
&setup.gui_state,
&std::time::Instant::now(),
"test".to_owned(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
let (bg, fg) = match (row_index, result_cell_index) {
(6..=8, 32..=44) => (Color::Red, Color::Black),
_ => (Color::Reset, Color::Reset),
};
assert_eq!(result_cell.bg, bg);
assert_eq!(result_cell.fg, fg);
}
}
}
}
-777
View File
@@ -1,777 +0,0 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::Rect,
style::{Style, Stylize},
text::Line,
widgets::{Block, Borders, Paragraph, Wrap},
};
use crate::{
app_data::InspectData,
config::{AppColors, Keymap},
ui::{
GuiState,
draw_blocks::{DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW},
gui_state::ScrollOffset,
},
};
/// Create a bordered block with a title.
fn title_block<'a>(upper_title: &'a str, lower_title: &'a str, colors: &AppColors) -> Block<'a> {
Block::default()
.borders(Borders::all())
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(colors.borders.selected))
.title(upper_title.bold().into_centered_line())
.title_bottom(lower_title.bold().into_centered_line())
}
/// Create the upper title, with container name, id, and keymap to clear
fn generate_upper_title(data: &InspectData, keymap: &Keymap) -> String {
let mut output = String::from(" inspecting: ");
let name = if data.name.starts_with("/") {
data.name.replacen('/', "", 1)
} else {
data.name.clone()
};
output.push_str(&format!("{} {} ", name, data.id.get_short()));
let mut inspect_key = keymap.inspect.0.to_string();
if let Some(x) = keymap.inspect.1 {
inspect_key.push_str(&format!(" or {x}"));
}
let mut clear_key = keymap.clear.0.to_string();
if let Some(x) = keymap.clear.1 {
clear_key.push_str(&format!(" or {x}"));
}
output.push_str(&format!(" - {clear_key} or {inspect_key} to exit"));
output.push(' ');
output
}
/// Generate the lower title, with the current scroll and the scrolling limits
fn generate_lower_title(length: usize, width: usize, offset: ScrollOffset) -> String {
let length_width = length
.to_string()
.chars()
.count()
.max(offset.y.to_string().chars().count());
let width_width = width
.to_string()
.chars()
.count()
.max(offset.x.to_string().chars().count());
let left_arrow = if offset.x == 0 { " " } else { LEFT_ARROW };
let right_arrow = if offset.x == width { " " } else { RIGHT_ARROW };
let up_arrow = if offset.y == 0 { " " } else { UP_ARROW };
let down_arrow = if offset.y == length { " " } else { DOWN_ARROW };
format!(
" {up_arrow} {:>length_width$}/{:>length_width$} {down_arrow} {left_arrow} {:>width_width$}/{:>width_width$} {right_arrow} ",
offset.y, length, offset.x, width
)
}
/// Generate the Lines, remove lines & chars based on the offset and viewport
fn gen_lines<'a>(data_as_str: &'a str, offset: &ScrollOffset, rect: &Rect) -> Vec<Line<'a>> {
let first_line_index = offset.y.max(0);
let first_char_index = offset.x.max(0);
let last_char_index = usize::from(rect.width.saturating_sub(2));
let take_lines = usize::from(rect.height);
//todo see if log scrolling does this - What?
data_as_str
.lines()
.skip(first_line_index)
.take(take_lines)
.map(|line| {
Line::from(
line.chars()
.skip(first_char_index)
.take(last_char_index)
.collect::<String>(),
)
})
.collect()
}
/// Draw the InspectContainer widget to the entire screen
pub fn draw(
f: &mut Frame,
colors: AppColors,
data: InspectData,
gui_state: &Arc<Mutex<GuiState>>,
keymap: &Keymap,
) {
let rect = f.area();
let offset = gui_state.lock().get_inspect_offset();
// +2 to account for the border
let height = data
.height
.saturating_sub(usize::from(rect.height))
.saturating_add(2);
let width = data
.width
.saturating_sub(usize::from(rect.width))
.saturating_add(2);
let upper_title = generate_upper_title(&data, keymap);
let lower_title = generate_lower_title(height, width, offset);
gui_state.lock().set_inspect_offset_max(ScrollOffset {
x: width,
y: height,
});
let paragraph = Paragraph::new(gen_lines(&data.as_string, &offset, &rect))
.block(title_block(&upper_title, &lower_title, &colors))
.gray()
.left_aligned()
.wrap(Wrap { trim: false });
f.render_widget(paragraph, rect);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::{collections::HashMap, sync::LazyLock};
use crate::{
app_data::InspectData,
config::{AppColors, Keymap},
ui::draw_blocks::tests::{get_result, test_setup},
};
use bollard::secret::{
ContainerConfig, ContainerInspectResponse, ContainerState, ContainerStateStatusEnum,
DriverData, EndpointSettings, HostConfig, HostConfigLogConfig, MountPoint,
MountPointTypeEnum, NetworkSettings, RestartPolicy, RestartPolicyNameEnum,
};
use crossterm::event::KeyCode;
use insta::assert_snapshot;
use ratatui::style::Color;
static INSPECT_DATA: LazyLock<InspectData> =
LazyLock::new(|| InspectData::from(gen_container_inspect_response()));
#[test]
/// Test a inspect container with default settings, keymap, and position
fn test_draw_blocks_inspect_default_valid() {
let mut setup = test_setup(100, 50, true, true);
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with custom colors
fn test_draw_blocks_inspect_custom_color() {
let mut setup = test_setup(100, 50, true, true);
let mut colors = AppColors::new();
colors.borders.selected = Color::Red;
setup
.terminal
.draw(|f| {
super::draw(
f,
colors,
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert custom border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Red);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with custom keymap for one clear key
fn test_draw_blocks_inspect_custom_keymap_clear_one() {
let mut setup = test_setup(100, 50, true, true);
let mut keymap = Keymap::new();
keymap.clear.0 = KeyCode::Char('F');
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with custom keymap for both clear keys
fn test_draw_blocks_inspect_custom_keymap_clear_two() {
let mut setup = test_setup(100, 50, true, true);
let mut keymap = Keymap::new();
keymap.clear.0 = KeyCode::Char('F');
keymap.clear.1 = Some(KeyCode::Char('Z'));
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with custom keymap for one inspect key
fn test_draw_blocks_inspect_custom_keymap_inspect_one() {
let mut setup = test_setup(100, 50, true, true);
let mut keymap = Keymap::new();
keymap.inspect.0 = KeyCode::Char('4');
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with custom keymap for both inspect keys
fn test_draw_blocks_inspect_custom_keymap_inspect_two() {
let mut setup = test_setup(100, 50, true, true);
let mut keymap = Keymap::new();
keymap.inspect.0 = KeyCode::Char('4');
keymap.inspect.1 = Some(KeyCode::Char('5'));
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Test a inspect container with all custom keymaps
fn test_draw_blocks_inspect_custom_keymap_all() {
let mut setup = test_setup(100, 50, true, true);
let mut keymap = Keymap::new();
keymap.clear.0 = KeyCode::Char('F');
keymap.clear.1 = Some(KeyCode::Char('Z'));
keymap.inspect.0 = KeyCode::Char('4');
keymap.inspect.1 = Some(KeyCode::Char('5'));
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&keymap,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
// Assert border colors
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Inspect details are offset 10 in x and y axis
fn test_draw_blocks_inspect_offset() {
let mut setup = test_setup(100, 50, true, true);
// Why does one need to draw first, although it *should* be impossible to scroll before an initial drawing
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
{
let mut gui_state = setup.gui_state.lock();
for _ in 0..=9 {
gui_state.set_inspect_offset(&crate::app_data::ScrollDirection::Down);
gui_state.set_inspect_offset(&crate::app_data::ScrollDirection::Right);
}
}
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
#[test]
/// Inspect details are offset to the maximum allowed
fn test_draw_blocks_inspect_offset_max() {
let mut setup = test_setup(100, 50, true, true);
// Why does one need to draw first, although it *should* be impossible to scroll before an initial drawing
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
// Lazy way of getting the max offset
{
let mut gui_state = setup.gui_state.lock();
for _ in 0..=1000 {
gui_state.set_inspect_offset(&crate::app_data::ScrollDirection::Down);
gui_state.set_inspect_offset(&crate::app_data::ScrollDirection::Right);
}
}
setup
.terminal
.draw(|f| {
super::draw(
f,
AppColors::new(),
INSPECT_DATA.clone(),
&setup.gui_state,
&Keymap::new(),
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 49, _) | (_, 0 | 99) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::LightCyan);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
}
}
}
}
fn gen_container_inspect_response() -> ContainerInspectResponse {
ContainerInspectResponse {
id: Some("0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7".to_owned()),
created: Some("2026-01-23T22:20:19.927967311Z".to_owned()),
path: Some("docker-entrypoint.sh".to_owned()),
args: Some(vec!["postgres".to_owned()]),
state: Some(ContainerState {
status: Some(ContainerStateStatusEnum::RUNNING),
running: Some(true),
paused: Some(false),
restarting: Some(false),
oom_killed: Some(false),
dead: Some(false),
pid: Some(782),
exit_code: Some(0),
error: Some("".to_owned()),
started_at: Some("2026-01-30T08:09:01.574885915Z".to_owned()),
finished_at: Some("2026-01-30T08:09:01.180567927Z".to_owned()),
health: None,
}),
image: Some("sha256:aa3668fcbcb5ded731b7d5c27065a4edf545debb7f27bf514c709b1b4e032352".to_owned()),
resolv_conf_path: Some("/var/lib/docker/containers/0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7/resolv.conf".to_owned()),
hostname_path: Some("/var/lib/docker/containers/0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7/hostname".to_owned()),
hosts_path: Some("/var/lib/docker/containers/0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7/hosts".to_owned()),
log_path: Some("/var/lib/docker/containers/0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7/0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7-json.log".to_owned()),
name: Some("/postgres".to_owned()),
restart_count: Some(0),
driver: Some("overlay2".to_owned()),
platform: Some("linux".to_owned()),
image_manifest_descriptor: None,
mount_label: Some("".to_owned()),
process_label: Some("".to_owned()),
app_armor_profile: Some("".to_owned()),
exec_ids: None,
host_config: Some(HostConfig {
cpu_shares: Some(0),
memory: Some(1073741824),
cgroup_parent: Some("".to_owned()),
blkio_weight: Some(0),
blkio_weight_device: None,
blkio_device_read_bps: None,
blkio_device_write_bps: None,
blkio_device_read_iops: None,
blkio_device_write_iops: None,
cpu_period: Some(0),
cpu_quota: Some(0),
cpu_realtime_period: Some(0),
cpu_realtime_runtime: Some(0),
cpuset_cpus: Some("".to_owned()),
cpuset_mems: Some("".to_owned()),
devices: None,
device_cgroup_rules: None,
device_requests: None,
memory_reservation: Some(0),
memory_swap: Some(2147483648),
memory_swappiness: None,
nano_cpus: Some(0),
oom_kill_disable: Some(false),
init: None,
pids_limit: None,
ulimits: None,
cpu_count: Some(0),
cpu_percent: Some(0),
io_maximum_iops: Some(0),
io_maximum_bandwidth: Some(0),
binds: None,
container_id_file: Some("".to_owned()),
log_config: Some(HostConfigLogConfig {
typ: Some("json-file".to_owned()),
config: Some(HashMap::new()),
}),
network_mode: Some("oxker-examaple-net".to_owned()),
port_bindings: Some(HashMap::new()),
restart_policy: Some(RestartPolicy {
name: Some(RestartPolicyNameEnum::ALWAYS),
maximum_retry_count: Some(0),
}),
auto_remove: Some(false),
volume_driver: Some("".to_owned()),
volumes_from: None,
mounts: None,
console_size: Some(vec![0, 0]),
annotations: None,
cap_add: None,
cap_drop: None,
cgroupns_mode: Some(bollard::secret::HostConfigCgroupnsModeEnum::HOST),
dns: Some(vec![]),
dns_options: Some(vec![]),
dns_search: Some(vec![]),
extra_hosts: Some(vec![]),
group_add: None,
ipc_mode: Some("private".to_owned()),
cgroup: Some("".to_owned()),
links: None,
oom_score_adj: Some(0),
pid_mode: Some("".to_owned()),
privileged: Some(false),
publish_all_ports: Some(false),
readonly_rootfs: Some(false),
security_opt: None,
storage_opt: None,
tmpfs: None,
uts_mode: Some("".to_owned()),
userns_mode: Some("".to_owned()),
shm_size: Some(268435456),
sysctls: None,
runtime: Some("runc".to_owned()),
isolation: Some(bollard::secret::HostConfigIsolationEnum::EMPTY),
masked_paths: Some(vec![
"/proc/acpi".to_owned(),
"/proc/asound".to_owned(),
"/proc/interrupts".to_owned(),
"/proc/kcore".to_owned(),
"/proc/keys".to_owned(),
"/proc/latency_stats".to_owned(),
"/proc/sched_debug".to_owned(),
"/proc/scsi".to_owned(),
"/proc/timer_list".to_owned(),
"/proc/timer_stats".to_owned(),
"/sys/devices/virtual/powercap".to_owned(),
"/sys/firmware".to_owned(),
]),
readonly_paths: Some(vec![
"/proc/bus".to_owned(),
"/proc/fs".to_owned(),
"/proc/irq".to_owned(),
"/proc/sys".to_owned(),
"/proc/sysrq-trigger".to_owned(),
]),
}),
graph_driver: Some(DriverData {
name: "overlay2".to_owned(),
data: HashMap::from([
("LowerDir".to_owned(), "/var/lib/docker/overlay2/b8dae7c82251b8dadc084dbcaceec47b3d48a5ba9d055a59934a8b88d18569ea-init/diff:/var/lib/docker/overlay2/51b93846f7ba3e00cb1ed86564e3e1d7c30df2bb1cd5a8469d54625f1e5a2eca/diff:/var/lib/docker/overlay2/c1364ead843d3af87ce286013b6301329d3089422b22b001e156e45d29b5b4dd/diff:/var/lib/docker/overlay2/0e6dc322cad77b1db3906a3a4e5e6d6b80fbffd138437e550d8849fcf4f4c1f2/diff:/var/lib/docker/overlay2/cc0f967a7471cf06e0c9ad3d474650c668a4cf0c02efe20e9c250c436f93033b/diff:/var/lib/docker/overlay2/5c59e0919969987c96a5d0e0a512a0a1a0f67ea747596af9a9c14a9566198d91/diff:/var/lib/docker/overlay2/d7709b7685c9704e1e392c515b6155517270541f6ccde426ef784403e1681fca/diff:/var/lib/docker/overlay2/c891528563fff91bffaf07416e77bcd3bdebb03e5d32ed0e3d4ee1ec5e80e880/diff:/var/lib/docker/overlay2/2b25c179a432c35cc599a082cd709c8c9a1523f8d1959f72fda21fc76e50ad00/diff:/var/lib/docker/overlay2/3b409d2f7a2455578148892302823a7f03c7c36482d08bb68fd6c1aeeec05f05/diff:/var/lib/docker/overlay2/55dbb2fab0ae8bb3bfe8183093cdd576686f7333e2b2c41e6e4178a7b6407554/diff".to_owned()),
("MergedDir".to_owned(), "/var/lib/docker/overlay2/b8dae7c82251b8dadc084dbcaceec47b3d48a5ba9d055a59934a8b88d18569ea/merged".to_owned()),
("WorkDir".to_owned(), "/var/lib/docker/overlay2/b8dae7c82251b8dadc084dbcaceec47b3d48a5ba9d055a59934a8b88d18569ea/work".to_owned()),
("ID".to_owned(), "0bdea64212f9c75eb4a1184dd406c2c79a986a7a889a23c85358456cc1bb60c7".to_owned()),
("UpperDir".to_owned(), "/var/lib/docker/overlay2/b8dae7c82251b8dadc084dbcaceec47b3d48a5ba9d055a59934a8b88d18569ea/diff".to_owned()),
]),
}),
storage: None,
size_rw: None,
size_root_fs: None,
mounts: Some(vec![MountPoint {
typ: Some(MountPointTypeEnum::VOLUME),
name: Some("93bc4e4c8d3823964b58105a99a7b3a7e02c801d5560338bdaf7589966a1b02d".to_owned()),
source: Some("/var/lib/docker/volumes/93bc4e4c8d3823964b58105a99a7b3a7e02c801d5560338bdaf7589966a1b02d/_data".to_owned()),
destination: Some("/var/lib/postgresql/data".to_owned()),
driver: Some("local".to_owned()),
mode: Some("".to_owned()),
rw: Some(true),
propagation: Some("".to_owned()),
}]),
config: Some(ContainerConfig {
hostname: Some("0bdea64212f9".to_owned()),
domainname: Some("".to_owned()),
user: Some("".to_owned()),
attach_stdin: Some(false),
attach_stdout: Some(true),
attach_stderr: Some(true),
exposed_ports: Some(vec!["5432/tcp".to_owned()]),
tty: Some(false),
open_stdin: Some(false),
stdin_once: Some(false),
env: Some(vec![
"POSTGRES_PASSWORD=never_use_this_password_in_production".to_owned(),
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_owned(),
"GOSU_VERSION=1.19".to_owned(),
"LANG=en_US.utf8".to_owned(),
"PG_MAJOR=17".to_owned(),
"PG_VERSION=17.7".to_owned(),
"PG_SHA256=ef9e343302eccd33112f1b2f0247be493cb5768313adeb558b02de8797a2e9b5".to_owned(),
"DOCKER_PG_LLVM_DEPS=llvm19-dev \t\tclang19".to_owned(),
"PGDATA=/var/lib/postgresql/data".to_owned(),
]),
cmd: Some(vec!["postgres".to_owned()]),
healthcheck: None,
args_escaped: None,
image: Some("postgres:17-alpine".to_owned()),
volumes: Some(vec!["/var/lib/postgresql/data".to_owned()]),
working_dir: Some("/".to_owned()),
entrypoint: Some(vec!["docker-entrypoint.sh".to_owned()]),
network_disabled: None,
on_build: None,
labels: Some(HashMap::from([
("com.docker.compose.oneoff".to_owned(), "False".to_owned()),
("com.docker.compose.project.config_files".to_owned(), "/workspaces/oxker/docker/docker-compose.yml".to_owned()),
("com.docker.compose.image".to_owned(), "sha256:aa3668fcbcb5ded731b7d5c27065a4edf545debb7f27bf514c709b1b4e032352".to_owned()),
("com.docker.compose.project.working_dir".to_owned(), "/workspaces/oxker/docker".to_owned()),
("com.docker.compose.service".to_owned(), "postgres".to_owned()),
("com.docker.compose.config-hash".to_owned(), "e06d69ffb3f9b69dd51b356b60c2297df57caf0da16792ccafaabffdb920e443".to_owned()),
("com.docker.compose.depends_on".to_owned(), "".to_owned()),
("com.docker.compose.container-number".to_owned(), "1".to_owned()),
("com.docker.compose.version".to_owned(), "2.40.3".to_owned()),
("com.docker.compose.project".to_owned(), "docker".to_owned()),
])),
stop_signal: Some("SIGINT".to_owned()),
stop_timeout: None,
shell: None,
}),
network_settings: Some(NetworkSettings {
sandbox_id: Some("dab64a66594dd8d06478184e2928c81acdcd9c931f643bd5ca62b7edb6345f8d".to_owned()),
sandbox_key: Some("/var/run/docker/netns/dab64a66594d".to_owned()),
ports: Some(HashMap::from([("5432/tcp".to_owned(), None)])),
networks: Some(HashMap::from([(
"oxker-examaple-net".to_owned(),
EndpointSettings {
ipam_config: None,
links: None,
mac_address: Some("a2:bd:4e:61:25:c7".to_owned()),
aliases: Some(vec!["postgres".to_owned(), "postgres".to_owned()]),
driver_opts: None,
gw_priority: Some(0),
network_id: Some("3cbeb475d81676f89a7aa205d8749ec2ad78d685e45d77b638992956f6dc569a".to_owned()),
endpoint_id: Some("31718069b2a3ea77487f3ece36b014d5d1329bc3294568e2621e5c0999071bed".to_owned()),
gateway: Some("172.19.0.1".to_owned()),
ip_address: Some("172.19.0.4".to_owned()),
ip_prefix_len: Some(16),
ipv6_gateway: Some("".to_owned()),
global_ipv6_address: Some("".to_owned()),
global_ipv6_prefix_len: Some(0),
dns_names: Some(vec!["postgres".to_owned(), "0bdea64212f9".to_owned()]),
},
)])),
}),
}
}
}
-571
View File
@@ -1,571 +0,0 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Modifier, Style, Stylize},
widgets::{List, Paragraph},
};
use crate::{
app_data::AppData,
config::AppColors,
ui::{FrameData, GuiState, SelectablePanel, Status},
};
use super::{SELECT_ARROW, generate_block};
/// Draw the logs panel
pub fn draw(
app_data: &Arc<Mutex<AppData>>,
area: Rect,
colors: AppColors,
f: &mut Frame,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
) {
let mut block = generate_block(area, colors, fd, gui_state, SelectablePanel::Logs);
if !fd.color_logs {
block = block.bg(colors.logs.background);
}
if fd.status.contains(&Status::Init) {
let mut paragraph = Paragraph::new(format!("parsing logs {}", fd.loading_icon))
.block(block)
.alignment(Alignment::Center);
if !fd.color_logs {
paragraph = paragraph.fg(colors.logs.text);
}
f.render_widget(paragraph, area);
} else {
let padding = usize::from(area.height / 5);
let logs = app_data.lock().get_logs(area.as_size(), padding);
if logs.is_empty() {
let mut paragraph = Paragraph::new("no logs found")
.block(block)
.alignment(Alignment::Center);
if !fd.color_logs {
paragraph = paragraph.fg(colors.logs.text);
}
f.render_widget(paragraph, area);
} else if fd.color_logs {
let items = List::new(logs)
.block(block)
.highlight_symbol(SELECT_ARROW)
.scroll_padding(padding)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
// This should always return Some, as logs is not empty
if let Some(log_state) = app_data.lock().get_log_state() {
f.render_stateful_widget(items, area, log_state);
}
} else {
let items = List::new(logs)
.fg(colors.logs.text)
.block(block)
.highlight_symbol(SELECT_ARROW)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
// This should always return Some, as logs is not empty
if let Some(log_state) = app_data.lock().get_log_state() {
f.render_stateful_widget(items, area, log_state);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use uuid::Uuid;
use crate::{
app_data::{ContainerImage, ContainerName, ScrollDirection},
config::AppColors,
ui::{
FrameData, Status,
draw_blocks::tests::{BORDER_CHARS, get_result, insert_logs, test_setup},
},
};
#[test]
/// No logs, panel unselected, then selected, border color changes correctly
fn test_draw_blocks_logs_none() {
let mut setup = test_setup(35, 6, true, true);
let colors = setup.app_data.lock().config.app_colors;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0 | 5, 0..=34) | (1..=4, 0) | (1..=5, 34) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
setup
.gui_state
.lock()
.selectable_panel_next(&setup.app_data);
setup
.gui_state
.lock()
.selectable_panel_next(&setup.app_data);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
// When selected, has a blue border
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (_, result_row) in get_result(&setup) {
for result_cell in result_row {
if BORDER_CHARS.contains(&result_cell.symbol()) {
assert_eq!(result_cell.fg, Color::LightCyan);
}
}
}
}
#[test]
/// Parsing logs, first frame spinner visible
fn test_draw_blocks_logs_parsing_frame_one() {
let mut setup = test_setup(32, 6, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Parsing logs, second frame spinner visible
fn test_draw_blocks_logs_parsing_frame_two() {
let mut setup = test_setup(32, 6, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
// animation moved by one frame
setup.gui_state.lock().next_loading(uuid);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 0..=31) | (1..=4, 0) | (1..=5, 31) | (5, 0..=30) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert_eq!(result_cell.bg, Color::Reset);
}
}
}
}
}
#[test]
/// Logs correct displayed, changing log state also draws correctly
fn test_draw_blocks_logs_some_line_three() {
let mut setup = test_setup(36, 6, true, true);
insert_logs(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
// let expected_row = expected_to_vec(&expected, row_index);
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
// assert_eq!(result_cell.symbol(), expected_row[result_cell_index]);
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 3 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Logs correct displayed, changing log state also draws correctly
fn test_draw_blocks_logs_some_line_two() {
let mut setup = test_setup(36, 6, true, true);
insert_logs(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
setup.app_data.lock().log_scroll(&ScrollDirection::Up);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
} else {
assert_eq!(result_cell.fg, Color::Gray);
}
if row_index == 2 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Full (long) name displayed in logs border
fn test_draw_blocks_logs_long_name() {
let mut setup = test_setup(80, 6, true, true);
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
insert_logs(&setup);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
AppColors::new(),
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
fn test_draw_blocks_logs_custom_colors_parsing() {
let mut setup = test_setup(32, 6, true, true);
let uuid = Uuid::new_v4();
setup.gui_state.lock().next_loading(uuid);
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.status.insert(Status::Init);
let mut colors = AppColors::new();
colors.logs.background = Color::Green;
colors.logs.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Green);
if let (1..=4, 1..=29) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Black);
}
}
}
fd.color_logs = true;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=29) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
#[test]
fn test_draw_blocks_logs_custom_colors_no_logs() {
let mut setup = test_setup(35, 6, true, true);
let mut colors = AppColors::new();
colors.logs.background = Color::Green;
colors.logs.text = Color::Black;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Green);
if let (1..=4, 1..=29) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Black);
}
}
}
setup.fd.color_logs = true;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&setup.fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=29) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
#[test]
/// Logs correct displayed with custom colors
fn test_draw_blocks_logs_custom_colors_logs() {
let mut setup = test_setup(36, 6, true, true);
insert_logs(&setup);
let mut colors = setup.app_data.lock().config.app_colors;
colors.logs.background = Color::Green;
colors.logs.text = Color::Black;
let mut fd = FrameData::from((&setup.app_data, &setup.gui_state));
fd.color_logs = true;
// Standard colors when color_logs is true
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Reset);
if row_index == 3 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
fd.color_logs = false;
setup
.terminal
.draw(|f| {
super::draw(
&setup.app_data,
setup.area,
colors,
f,
&fd,
&setup.gui_state,
);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Green);
if let (1..=4, 1..=34) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Black);
if row_index == 3 && (1..=34).contains(&result_cell_index) {
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
}
-556
View File
@@ -1,556 +0,0 @@
use std::sync::Arc;
use parking_lot::Mutex;
use ratatui::{
layout::{Constraint, Rect},
style::Style,
widgets::{Block, BorderType, Borders},
};
use crate::config::AppColors;
use super::{FrameData, GuiState, SelectablePanel, Status, gui_state::Region};
pub mod chart_bandwidth;
pub mod chart_cpu_mem;
pub mod commands;
pub mod containers;
pub mod delete_confirm;
pub mod error;
pub mod filter;
pub mod headers;
pub mod help;
pub mod info;
pub mod inspect;
pub mod logs;
pub mod popup;
pub mod ports;
pub mod search_logs;
pub const NAME_TEXT: &str = r#" 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 "#;
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub const REPO: &str = env!("CARGO_PKG_REPOSITORY");
pub const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
pub const MARGIN: &str = " ";
pub const SELECT_ARROW: &str = "";
pub const LEFT_ARROW: &str = "";
pub const RIGHT_ARROW: &str = "";
pub const DOWN_ARROW: &str = "";
pub const UP_ARROW: &str = "";
pub const CIRCLE: &str = "";
#[cfg(not(test))]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(test)]
pub const VERSION: &str = "0.00.000";
pub const CONSTRAINT_50_50: [Constraint; 2] =
[Constraint::Percentage(50), Constraint::Percentage(50)];
pub const CONSTRAINT_100: [Constraint; 1] = [Constraint::Percentage(100)];
pub const CONSTRAINT_POPUP: [Constraint; 5] = [
Constraint::Min(2),
Constraint::Max(1),
Constraint::Max(1),
Constraint::Max(3),
Constraint::Min(1),
];
pub const CONSTRAINT_BUTTONS: [Constraint; 5] = [
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
Constraint::Percentage(35),
Constraint::Percentage(10),
];
/// From a given &str, return the maximum number of chars on a single line
pub fn max_line_width(text: &str) -> usize {
text.lines()
.map(|i| i.chars().count())
.max()
.unwrap_or_default()
}
/// Generate block, add a border if is the selected panel,
/// add custom title based on state of each panel
fn generate_block<'a>(
area: Rect,
colors: AppColors,
fd: &FrameData,
gui_state: &Arc<Mutex<GuiState>>,
panel: SelectablePanel,
) -> Block<'a> {
gui_state
.lock()
.update_region_map(Region::Panel(panel), area);
let mut title = match panel {
SelectablePanel::Containers => {
format!("{}{}", panel.title(), fd.container_title)
}
SelectablePanel::Logs => {
format!("{}{}", panel.title(), fd.log_title)
}
SelectablePanel::Commands => String::new(),
};
if !title.is_empty() {
title = format!(" {title} ");
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(ratatui::text::Line::from(title).left_aligned());
if panel == SelectablePanel::Logs
&& let Some(x) = fd.scroll_title.as_ref()
{
block = block
.title_bottom(x.to_owned())
.title_alignment(ratatui::layout::Alignment::Right);
}
if !fd.status.contains(&Status::Filter) {
if fd.selected_panel == panel {
block = block.border_style(Style::default().fg(colors.borders.selected));
} else {
block = block.border_style(Style::default().fg(colors.borders.unselected));
}
}
block
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub mod tests {
use std::{
net::{IpAddr, Ipv4Addr},
sync::Arc,
};
use insta::assert_snapshot;
use parking_lot::Mutex;
use ratatui::{Terminal, backend::TestBackend, layout::Rect, style::Color};
use crate::{
app_data::{AppData, ContainerId, ContainerImage, ContainerName, ContainerPorts},
app_error::AppError,
tests::{gen_appdata, gen_containers},
ui::{GuiState, Rerender, Status, draw_frame},
};
use super::FrameData;
pub struct TuiTestSetup {
pub app_data: Arc<Mutex<AppData>>,
pub gui_state: Arc<Mutex<GuiState>>,
pub fd: FrameData,
pub area: Rect,
pub terminal: Terminal<TestBackend>,
pub ids: Vec<ContainerId>,
}
pub const BORDER_CHARS: [&str; 6] = ["", "", "", "", "", ""];
pub const COLOR_RX: Color = Color::Rgb(255, 233, 193);
pub const COLOR_TX: Color = Color::Rgb(205, 140, 140);
pub const COLOR_ORANGE: Color = Color::Rgb(255, 178, 36);
/// Create a FrameData struct from two Arc<mutex>'s, instead of from UI
impl From<(&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)> for FrameData {
fn from(data: (&Arc<Mutex<AppData>>, &Arc<Mutex<GuiState>>)) -> Self {
let (mut app_data, gui_data) = (data.0.lock(), data.1.lock());
let (filter_by, filter_term) = app_data.get_filter();
Self {
chart_data: app_data.get_chart_data(),
color_logs: app_data.config.color_logs,
columns: app_data.get_width(),
container_title: app_data.get_container_title(),
delete_confirm: gui_data.get_delete_container(),
filter_by,
filter_term: filter_term.cloned(),
has_containers: app_data.get_container_len() > 0,
has_error: app_data.get_error(),
show_logs: gui_data.get_show_logs(),
info_text: gui_data.info_box_text.clone(),
is_loading: gui_data.is_loading(),
log_search: app_data.gen_log_search(),
loading_icon: gui_data.get_loading().to_string(),
log_height: gui_data.get_log_height(),
log_title: app_data.get_log_title(),
scroll_title: app_data.get_scroll_title(gui_data.get_screen_width()),
port_max_lens: app_data.get_longest_port(),
ports: app_data.get_selected_ports(),
selected_panel: gui_data.get_selected_panel(),
sorted_by: app_data.get_sorted(),
status: gui_data.get_status(),
}
}
}
/// Generate state to be used in *most* gui tests
pub fn test_setup(w: u16, h: u16, control_start: bool, container_start: bool) -> TuiTestSetup {
let backend = TestBackend::new(w, h);
let terminal = Terminal::new(backend).unwrap();
let (ids, containers) = gen_containers();
let mut app_data = gen_appdata(&containers);
if control_start {
app_data.docker_controls_start();
}
if container_start {
app_data.containers_start();
}
let redraw = Arc::new(Rerender::new());
let gui_state = GuiState::new(&redraw, app_data.config.show_logs);
let app_data = Arc::new(Mutex::new(app_data));
let gui_state = Arc::new(Mutex::new(gui_state));
let fd = FrameData::from((&app_data, &gui_state));
let area = Rect::new(0, 0, w, h);
gui_state.lock().set_screen_width(w);
TuiTestSetup {
app_data,
gui_state,
fd,
area,
terminal,
ids,
}
}
/// Just a shorthand for when enumerating over result cells
pub fn get_result(
setup: &'_ TuiTestSetup,
) -> std::iter::Enumerate<std::slice::Chunks<'_, ratatui::buffer::Cell>> {
setup
.terminal
.backend()
.buffer()
.content
.chunks(usize::from(setup.area.width))
.enumerate()
}
/// Insert some logs into the first container
pub fn insert_logs(setup: &TuiTestSetup) {
let logs = (1..=3).map(|i| format!("{i} line {i}")).collect::<Vec<_>>();
setup.app_data.lock().update_log_by_id(logs, &setup.ids[0]);
}
#[allow(clippy::cast_precision_loss)]
// Add fixed data to the cpu & mem vecdeques
pub fn insert_all_chart_data(setup: &TuiTestSetup) {
for i in 1..=10 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
for i in 1..=3 {
setup.app_data.lock().update_stats_by_id(
&setup.ids[0],
Some(i as f64),
Some(i * 10000),
i * 10000,
i,
i,
);
}
}
// *************** //
// The whole layout //
// **************** //
#[test]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout() {
let mut setup = test_setup(160, 30, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
#[allow(clippy::too_many_lines)]
/// Check that the whole layout is drawn correctly
fn test_draw_blocks_whole_layout_with_filter_bar() {
let mut setup = test_setup(160, 30, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[1]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.gui_state
.lock()
.status_push(crate::ui::Status::Filter);
setup.app_data.lock().filter_term_push('r');
setup.app_data.lock().filter_term_push('_');
setup.app_data.lock().filter_term_push('1');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn correctly when have long container name and long image name
fn test_draw_blocks_whole_layout_long_name() {
let mut setup = test_setup(190, 30, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
setup.app_data.lock().containers.items[0].name =
ContainerName::from("a_long_container_name_for_the_purposes_of_this_test");
setup.app_data.lock().containers.items[0].image =
ContainerImage::from("a_long_image_name_for_the_purposes_of_this_test");
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn correctly when the logs panel is removed
fn test_draw_blocks_whole_layout_no_logs() {
let mut setup = test_setup(160, 30, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup.gui_state.lock().log_height_zero();
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn correctly when the logs panel height is ~4
fn test_draw_blocks_whole_layout_short_height_logs() {
let mut setup = test_setup(160, 30, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup.gui_state.lock().log_height_zero();
for _ in 0..=3 {
setup.gui_state.lock().log_height_increase();
}
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn with the help panel visible
fn test_draw_blocks_whole_layout_help_panel() {
let mut setup = test_setup(160, 40, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup.gui_state.lock().status_push(Status::Help);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn with the error box is visible
fn test_draw_blocks_whole_layout_error() {
let mut setup = test_setup(160, 40, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup.app_data.lock().set_error(
AppError::DockerCommand(crate::app_data::DockerCommand::Pause),
&setup.gui_state,
Status::Error,
);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn with the delete box is visible
fn test_draw_blocks_whole_layout_delete() {
let mut setup = test_setup(160, 40, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup
.gui_state
.lock()
.set_delete_container(setup.app_data.lock().get_selected_container_id());
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Check that the whole layout is drawn with the info box is visible
fn test_draw_blocks_whole_layout_info_box() {
let mut setup = test_setup(160, 40, true, true);
insert_all_chart_data(&setup);
insert_logs(&setup);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let colors = setup.app_data.lock().config.app_colors;
let keymap = setup.app_data.lock().config.keymap.clone();
setup.gui_state.lock().set_info_box("This is a test");
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
draw_frame(&setup.app_data, colors, &keymap, f, &fd, &setup.gui_state);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
}
-31
View File
@@ -1,31 +0,0 @@
use ratatui::layout::{Direction, Layout, Rect};
use crate::ui::gui_state::BoxLocation;
/// draw a box in the one of the BoxLocations, based on max line width + number of lines
pub fn draw(text_lines: usize, text_width: usize, r: Rect, box_location: BoxLocation) -> Rect {
// Make sure blank_space can't be an negative, as will crash
let calc = |x: u16, y: usize| usize::from(x).saturating_sub(y).saturating_div(2);
let blank_vertical = calc(r.height, text_lines);
let blank_horizontal = calc(r.width, text_width);
let (h_constraints, v_constraints) = box_location.get_constraints(
blank_horizontal.try_into().unwrap_or_default(),
blank_vertical.try_into().unwrap_or_default(),
text_lines.try_into().unwrap_or_default(),
text_width.try_into().unwrap_or_default(),
);
let indexes = box_location.get_indexes();
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(v_constraints)
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints(h_constraints)
.split(popup_layout[indexes.0])[indexes.1]
}
-385
View File
@@ -1,385 +0,0 @@
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
use crate::{app_data::State, config::AppColors, ui::FrameData};
/// Get the port title color, at the moment the color is only customizable if the container is alive
const fn get_port_title_color(colors: AppColors, state: State) -> Color {
if state.is_alive() {
colors.chart_ports.title
} else {
state.get_color(colors)
}
}
/// Display the ports in a formatted list
pub fn draw(area: Rect, colors: AppColors, f: &mut Frame, fd: &FrameData) {
if let Some(ports) = fd.ports.as_ref() {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::new().fg(colors.chart_ports.border))
.title_alignment(Alignment::Center)
.title(Span::styled(
" ports ",
Style::default()
.fg(get_port_title_color(colors, ports.1))
.bg(colors.chart_ports.background)
.add_modifier(Modifier::BOLD),
));
let (ip, private, public) = fd.port_max_lens;
if ports.0.is_empty() {
let text = match ports.1 {
State::Running(_) | State::Paused | State::Restarting => "no ports",
_ => "",
};
let paragraph = Paragraph::new(Span::from(text).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(block)
.bg(colors.chart_ports.background);
f.render_widget(paragraph, area);
} else {
let mut output = vec![Line::from(
Span::from(format!(
"{:>ip$}{:>private$}{:>public$}",
"ip", "private", "public"
))
.fg(colors.chart_ports.headings),
)];
for item in &ports.0 {
let strings = item.get_all();
let line = vec![
Span::from(format!("{:>ip$}", strings.0)).fg(colors.chart_ports.text),
Span::from(format!("{:>private$}", strings.1)).fg(colors.chart_ports.text),
Span::from(format!("{:>public$}", strings.2)).fg(colors.chart_ports.text),
];
output.push(Line::from(line));
}
let paragraph = Paragraph::new(output)
.block(block)
.bg(colors.chart_ports.background);
f.render_widget(paragraph, area);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
app_data::{ContainerPorts, RunningState, State},
config::AppColors,
ui::{
FrameData,
draw_blocks::tests::{COLOR_ORANGE, COLOR_RX, COLOR_TX, get_result, test_setup},
},
};
#[test]
/// Port section when container has no ports
fn test_draw_blocks_ports_no_ports() {
let mut setup = test_setup(30, 8, true, true);
setup.app_data.lock().containers.items[0].ports = vec![];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match (row_index, result_cell_index) {
(0, 11..=17) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 11..=18) => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Port section when container has no ports
// When state is "State::Running | State::Paused | State::Restarting, won't show "no ports"
fn test_draw_blocks_ports_no_ports_dead() {
let mut setup = test_setup(30, 8, true, true);
setup.app_data.lock().containers.items[0].ports = vec![];
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
// split
setup.app_data.lock().containers.items[0].state = State::Dead;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 11..=17) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
} else {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
}
}
}
#[test]
/// Port section when container has multiple ports
fn test_draw_blocks_ports_multiple_ports() {
let mut setup = test_setup(32, 8, true, true);
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: None,
private: 8002,
public: None,
});
setup.app_data.lock().containers.items[0]
.ports
.push(ContainerPorts {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
private: 8003,
public: Some(8003),
});
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
match (row_index, result_cell_index) {
(0, 12..=18) => {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
(1, 1..=28) => {
assert_eq!(result_cell.fg, Color::Yellow);
assert!(result_cell.modifier.is_empty());
}
(2..=4, 1..=28) | (0 | 2..=9, 0..=31) | (1, 0 | 29..=31) => {
assert_eq!(result_cell.fg, Color::White);
assert!(result_cell.modifier.is_empty());
}
_ => {
assert_eq!(result_cell.fg, Color::Reset);
assert!(result_cell.modifier.is_empty());
}
}
}
}
}
#[test]
/// Port section title color correct dependant on state
fn test_draw_blocks_ports_container_state() {
let mut setup = test_setup(32, 8, true, true);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Green);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Paused;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Yellow);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
setup.app_data.lock().containers.items[0].state = State::Exited;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, setup.app_data.lock().config.app_colors, f, &fd);
})
.unwrap();
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Reset);
if let (0, 12..=18) = (row_index, result_cell_index) {
assert_eq!(result_cell.fg, Color::Red);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
}
}
}
#[test]
/// Custom colors applied to ports panel
fn test_draw_blocks_ports_custom_colors() {
let mut setup = test_setup(32, 8, true, true);
let mut colors = AppColors::new();
colors.chart_ports.background = Color::Black;
colors.chart_ports.border = Color::Yellow;
colors.chart_ports.headings = Color::Red;
colors.chart_ports.text = Color::Green;
colors.chart_ports.title = Color::Magenta;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
assert_eq!(result_cell.bg, Color::Black);
match (row_index, result_cell_index) {
// title => {
(0, 12..=18) => {
assert_eq!(result_cell.fg, Color::Magenta);
}
// title
(1, 1..=24) => {
assert_eq!(result_cell.fg, Color::Red);
}
// text
(2, 1..=24) => {
assert_eq!(result_cell.fg, Color::Green);
}
// border & everything else
_ => {
assert_eq!(result_cell.fg, Color::Yellow);
}
}
}
}
}
#[test]
// Custom state color applied to ports panel title
fn test_draw_blocks_ports_custom_colors_state() {
let mut setup = test_setup(32, 8, true, true);
let mut colors = AppColors::new();
colors.container_state.dead = Color::Green;
colors.container_state.exited = Color::Magenta;
colors.container_state.paused = Color::Gray;
colors.container_state.removing = COLOR_ORANGE;
colors.container_state.restarting = COLOR_RX;
colors.container_state.running_healthy = COLOR_TX;
colors.container_state.running_unhealthy = Color::Cyan;
colors.container_state.unknown = Color::LightMagenta;
colors.chart_ports.title = Color::DarkGray;
for i in [
(State::Dead, Color::Green),
(State::Exited, Color::Magenta),
(State::Paused, Color::Gray),
(State::Removing, COLOR_ORANGE),
(State::Restarting, COLOR_RX),
(State::Unknown, Color::LightMagenta),
(State::Running(RunningState::Healthy), Color::DarkGray),
(State::Running(RunningState::Unhealthy), Color::DarkGray),
] {
setup.app_data.lock().containers.items[0].state = i.0;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd);
})
.unwrap();
// assert_snapshot!(setup.terminal.backend());
for (row_index, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
if row_index == 0 && (12..=18).contains(&result_cell_index) {
assert_eq!(result_cell.fg, i.1);
}
}
}
}
}
}
-479
View File
@@ -1,479 +0,0 @@
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::{
app_data::LogsButton,
config::{AppColors, Keymap},
ui::FrameData,
};
// background, text, selected_text, highlight;
/// Draw the filter bar
pub fn draw(area: Rect, colors: AppColors, frame: &mut Frame, fd: &FrameData, keymap: &Keymap) {
let style_but = Style::default()
.fg(colors.log_search.button_text)
.bg(colors.log_search.highlight);
let style_desc = Style::default()
.fg(colors.log_search.text)
.bg(colors.log_search.background);
let space = || Span::from(" ");
let mut line = vec![
Span::styled(" Esc ", style_but),
Span::styled(" clear ", style_desc),
space(),
];
line.extend([Span::styled(
" search term: ",
Style::default()
.fg(colors.log_search.highlight)
.bg(colors.log_search.background)
.add_modifier(Modifier::BOLD),
)]);
if let Some(log_search) = fd.log_search.as_ref() {
line.extend([
Span::styled(
log_search
.term
.as_ref()
.map_or(String::new(), std::clone::Clone::clone),
Style::default()
.fg(colors.log_search.text)
.bg(colors.log_search.background),
),
space(),
]);
}
let left_text = Paragraph::new(Line::from(line))
.alignment(ratatui::layout::Alignment::Left)
.style(Style::default().bg(colors.log_search.background));
let mut line = vec![];
if let Some(log_search) = fd.log_search.as_ref() {
if let Some(buttons) = log_search.buttons.as_ref() {
let down = if keymap.scroll_down.0 == KeyCode::Down {
"".to_owned()
} else {
keymap.scroll_down.0.to_string()
};
let up = if keymap.scroll_up.0 == KeyCode::Up {
"".to_owned()
} else {
keymap.scroll_up.0.to_string()
};
let next = [
space(),
Span::styled(format!(" {up} "), style_but),
Span::styled(" next ", style_desc),
];
let previous = [
space(),
Span::styled(format!(" {down} "), style_but),
Span::styled(" previous ", style_desc),
];
match buttons {
LogsButton::Both => line.extend(previous.into_iter().chain(next)),
LogsButton::Next => line.extend(next),
LogsButton::Previous => line.extend(previous),
}
}
if let Some(results) = log_search.result.as_ref() {
line.extend([
Span::styled(
" matches: ",
Style::default()
.fg(colors.log_search.highlight)
.bg(colors.log_search.background)
.add_modifier(Modifier::BOLD),
),
Span::styled(
results,
Style::default()
.fg(colors.log_search.text)
.bg(colors.log_search.background),
),
]);
}
}
let right_text = Paragraph::new(Line::from(line))
.alignment(ratatui::layout::Alignment::Right)
.style(Style::default().bg(colors.log_search.background));
let line_split = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
frame.render_widget(left_text, line_split[0]);
frame.render_widget(right_text, line_split[1]);
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crossterm::event::KeyCode;
use insta::assert_snapshot;
use ratatui::style::{Color, Modifier};
use crate::{
config::{AppColors, Keymap},
ui::{
FrameData,
draw_blocks::tests::{get_result, insert_logs, test_setup},
},
};
#[test]
/// Filter row is drawn correctly & colors are correct
/// Colours change when filter_by option is changed
fn test_draw_blocks_log_search_row() {
let mut setup = test_setup(140, 1, true, true);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &setup.fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
13..=26 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Log item found, previous button visible
fn test_draw_blocks_log_search_match_previous() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('e');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 114..=116 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27 | 117..=126 | 137..=139 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
13..=26 | 127..=136 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Log item found, next button visible
fn test_draw_blocks_log_search_match_next() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('e');
setup
.app_data
.lock()
.log_scroll(&crate::app_data::ScrollDirection::Up);
setup
.app_data
.lock()
.log_scroll(&crate::app_data::ScrollDirection::Up);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 118..=120 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27 | 121..=126 | 137..=139 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
13..=26 | 127..=136 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Log item found, next & previous button visible
fn test_draw_blocks_log_search_match_both_next_previous() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('e');
setup
.app_data
.lock()
.log_scroll(&crate::app_data::ScrollDirection::Up);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 104..=106 | 118..=120 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27 | 107..=116 | 121..=126 | 137..=139 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
13..=26 | 127..=136 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// No log item found
fn test_draw_blocks_log_search_match_none() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('z');
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 => {
assert_eq!(result_cell.bg, Color::Magenta);
assert_eq!(result_cell.fg, Color::Black);
}
5..=11 | 27 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Gray);
}
13..=26 => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Magenta);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::Reset);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
#[test]
/// Custom keymap for scroll buttons
fn test_draw_blocks_log_search_keymap() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
let mut keymap = setup.app_data.lock().config.keymap.clone();
keymap.scroll_up = (KeyCode::Char('a'), None);
keymap.scroll_down = (KeyCode::Char('b'), None);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('e');
setup
.app_data
.lock()
.log_scroll(&crate::app_data::ScrollDirection::Up);
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, AppColors::new(), f, &fd, &keymap);
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
}
#[test]
/// Custom colours applied
fn test_draw_blocks_log_search_colors() {
let mut setup = test_setup(140, 1, true, true);
insert_logs(&setup);
setup
.gui_state
.lock()
.status_push(crate::ui::Status::SearchLogs);
setup.app_data.lock().log_search_push('e');
setup
.app_data
.lock()
.log_scroll(&crate::app_data::ScrollDirection::Up);
let mut colors = AppColors::new();
colors.log_search.background = Color::White;
colors.log_search.highlight = Color::Blue;
colors.log_search.button_text = Color::Yellow;
colors.log_search.text = Color::Magenta;
let fd = FrameData::from((&setup.app_data, &setup.gui_state));
setup
.terminal
.draw(|f| {
super::draw(setup.area, colors, f, &fd, &Keymap::new());
})
.unwrap();
assert_snapshot!(setup.terminal.backend());
for (_, result_row) in get_result(&setup) {
for (result_cell_index, result_cell) in result_row.iter().enumerate() {
match result_cell_index {
0..=4 | 104..=106 | 118..=120 => {
assert_eq!(result_cell.bg, Color::Blue);
assert_eq!(result_cell.fg, Color::Yellow);
}
5..=11 | 27 | 107..=116 | 121..=126 | 137..=139 => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Magenta);
}
13..=26 | 127..=136 => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Blue);
assert_eq!(result_cell.modifier, Modifier::BOLD);
}
_ => {
assert_eq!(result_cell.bg, Color::White);
assert_eq!(result_cell.fg, Color::Reset);
}
}
}
}
}
}
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭─── rx: 566.00 kb/s tx: 56.60 kb/s ───╮"
"│ │ • │"
"│ │ •• │"
"│ │ •• │"
"│566.00 kb/s│ •• │"
"│ │ • │"
"│56.60 kb/s │ •• │"
"│ │•• ••• │"
"│ │•••••• │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭──── rx: 0.00 kb/s tx: 0.00 kb/s ─────╮"
"│ │ │"
"│ │ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│ │ │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭──── rx: 0.00 kb/s tx: 0.00 kb/s ─────╮"
"│ │ │"
"│ │ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│ │ │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭──── rx: 0.00 kb/s tx: 0.00 kb/s ─────╮"
"│ │ │"
"│ │ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│0.00 kb/s│ │"
"│ │ │"
"│ │ │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭─── rx: 0.00 kb/s tx: 205.00 kb/s ────╮"
"│ │ • │"
"│ │ •• │"
"│ │ •• │"
"│205.00 kb/s│ •• │"
"│ │ • │"
"│0.00 kb/s │ •• │"
"│ │•• │"
"│ │ │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭─── rx: 566.00 kb/s tx: 0.00 kb/s ────╮"
"│ │ • │"
"│ │ •• │"
"│ │ •• │"
"│566.00 kb/s│ •• │"
"│ │ • │"
"│0.00 kb/s │ •• │"
"│ │•• │"
"│ │• │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭─── rx: 0.00 kb/s tx: 205.00 kb/s ────╮"
"│ │ • │"
"│ │ •• │"
"│ │ •• │"
"│205.00 kb/s│ •• │"
"│ │ • │"
"│0.00 kb/s │ •• │"
"│ │•• │"
"│ │ │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_bandwidth.rs
expression: setup.terminal.backend()
---
"╭─── rx: 566.00 kb/s tx: 56.60 kb/s ───╮"
"│ │ • │"
"│ │ •• │"
"│ │ •• │"
"│566.00 kb/s│ •• │"
"│ │ • │"
"│56.60 kb/s │ •• │"
"│ │•• ••• │"
"│ │•••••• │"
"╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_cpu_mem.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_cpu_mem.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_cpu_mem.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_cpu_mem.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 00.00% ─────────────╮╭─────────── memory 0.00 kB ───────────╮"
"│00.00%│ ││0.00 kB│ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/chart_cpu_mem.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/charts.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/charts.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/charts.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/charts.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 00.00% ─────────────╮╭─────────── memory 0.00 kB ───────────╮"
"│00.00%│ ││0.00 kB│ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,14 +0,0 @@
---
source: src/ui/draw_blocks/charts.rs
expression: setup.terminal.backend()
---
"╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮"
"│10.00%│ • ││100.00 kB│ • │"
"│ │ •• ││ │ •• │"
"│ │ • • ││ │ •• │"
"│ │ • • ││ │ • • │"
"│ │ • • ││ │ •• • │"
"│ │ • •• ││ │ • • │"
"│ │•• •• ││ │• • │"
"│ │ ││ │ │"
"╰──────────────────────────────────────╯╰──────────────────────────────────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│ resume │"
"│▶ stop │"
"│ delete │"
"│ │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│▶ pause │"
"│ restart │"
"│ stop │"
"│ delete │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│ │"
"│ │"
"│ │"
"│ │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│▶ pause │"
"│ restart │"
"│ stop │"
"│ delete │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│▶ pause │"
"│ restart │"
"│ stop │"
"│ delete │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/commands.rs
expression: setup.terminal.backend()
---
"╭──────────╮"
"│ resume │"
"│▶ stop │"
"│ delete │"
"│ │"
"╰──────────╯"
@@ -1,10 +0,0 @@
---
source: src/ui/draw_blocks/containers.rs
expression: setup.terminal.backend()
---
"╭ Containers 1/3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│⚪ container_1 ✓ running Up 1 hour 00.00% 0.00 kB / 0.00 kB 1 image_1 0.00 kB 0.00 kB │" Hidden by multi-width symbols: [(2, " ")]
"│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB │"
"│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB │"
"│ │"
"╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"

Some files were not shown because too many files have changed in this diff Show More