oxker 0.13.0

A simple tui to view & control docker containers
// #![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,
        }
    }
}