diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b8b1ec..239d97e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,11 +20,28 @@ FetchContent_Declare( FetchContent_MakeAvailable(ftxui) -add_executable(tux-dock main.cpp) +include(FetchContent) + +FetchContent_Declare( + json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(json) + +add_executable(tux-dock + main.cpp + src/process_runner.cpp + src/docker_engine_client.cpp + src/docker_manager.cpp +) target_link_libraries(tux-dock PRIVATE ftxui::screen ftxui::dom ftxui::component + nlohmann_json::nlohmann_json ) + +target_compile_options(tux-dock PRIVATE -Wall -Wextra -Wpedantic) diff --git a/README.md b/README.md index 53f1158..01a6212 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,9 @@ Current TUI actions: 10. Attach Shell to Running Container 11. Run Detached Command in Container 12. Spin Up MySQL Container -13. Get Container IP Address -14. Create Dockerfile & Build Image from Bash Script -15. About Tux-Dock -16. Exit +13. Create Dockerfile & Build Image from Bash Script +14. About Tux-Dock +15. Exit --- @@ -99,7 +98,6 @@ public: bool execShell(...); bool execDetachedCommand(...); bool spinUpMySQL(...); - bool showContainerIP(...); bool createDockerfile(...); }; diff --git a/main.cpp b/main.cpp index 6e9bc8e..6c4af52 100644 --- a/main.cpp +++ b/main.cpp @@ -1,7 +1,6 @@ -#include +#include "src/docker_manager.hpp" + #include -#include -#include #include #include #include @@ -17,1296 +16,220 @@ #include #include -class DockerManager { -public: - struct ContainerInfo { - std::string id; - std::string name; - std::string status; - std::string ports; - bool running = false; - }; - - std::vector getContainerList() const; - std::vector> getImageList() const; - - bool pullImage(const std::string& image, std::string& message) const; - bool runContainerInteractive(const std::string& image, - const std::vector& ports, - std::string& message) const; - bool startInteractive(const std::string& containerId, std::string& message) const; - bool startDetached(const std::string& containerId, std::string& message) const; - bool deleteImage(const std::string& imageId, std::string& message) const; - bool stopContainer(const std::string& containerId, std::string& message) const; - bool removeContainer(const std::string& containerId, std::string& message) const; - bool execShell(const std::string& containerId, std::string& message) const; - bool execDetachedCommand(const std::string& containerId, - const std::string& command, - std::string& message) const; - bool spinUpMySQL(const std::string& port, - const std::string& password, - const std::string& version, - std::string& message) const; - bool showContainerIP(const std::string& containerId, std::string& message) const; - bool createDockerfile(const std::string& baseImage, - const std::string& bashScriptPath, - std::string outputFile, - const std::string& imageName, - std::string& message) const; - -private: - static bool runCommand(const std::string& cmd, bool quiet = true); - static std::string shellEscape(const std::string& value); -}; - -bool DockerManager::runCommand(const std::string& cmd, bool quiet) { - std::string command = cmd; - if (quiet) { - command += " > /dev/null 2>&1"; - } - const int code = std::system(command.c_str()); - return code == 0; -} - -std::string DockerManager::shellEscape(const std::string& value) { - std::string escaped = "'"; - for (char c : value) { - if (c == '\'') { - escaped += "'\\''"; - } else { - escaped += c; - } - } - escaped += "'"; - return escaped; -} - -std::vector> DockerManager::getImageList() const { - std::vector> images; - std::array buffer{}; - std::string result; - FILE* pipe = popen("docker images --format '{{.ID}} {{.Repository}}:{{.Tag}}'", "r"); - if (!pipe) { - return images; - } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - result = buffer.data(); - std::stringstream ss(result); - std::string id; - std::string repoTag; - ss >> id >> repoTag; - if (!id.empty() && !repoTag.empty()) { - images.emplace_back(id, repoTag); - } - } - pclose(pipe); - return images; -} - -std::vector DockerManager::getContainerList() const { - std::vector containers; - std::array buffer{}; - std::string result; - - FILE* pipe = popen("docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}'", "r"); - if (!pipe) { - return containers; - } - - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - result = buffer.data(); - while (!result.empty() && (result.back() == '\n' || result.back() == '\r')) { - result.pop_back(); - } - - std::stringstream ss(result); - ContainerInfo info; - std::getline(ss, info.id, '\t'); - std::getline(ss, info.name, '\t'); - std::getline(ss, info.status, '\t'); - std::getline(ss, info.ports); - - if (!info.id.empty() && !info.name.empty()) { - info.running = info.status.rfind("Up", 0) == 0; - containers.push_back(info); - } - } - pclose(pipe); - return containers; -} - -bool DockerManager::pullImage(const std::string& image, std::string& message) const { - if (image.empty()) { - message = "Please provide an image name."; - return false; - } - const bool ok = runCommand("docker pull " + shellEscape(image)); - message = ok ? "Image pulled successfully." : "Could not pull that image."; - return ok; -} - -bool DockerManager::runContainerInteractive(const std::string& image, - const std::vector& ports, - std::string& message) const { - if (image.empty()) { - message = "Please choose an image first."; - return false; - } - - std::string cmd = "docker run -it "; - for (const auto& port : ports) { - cmd += "-p " + shellEscape(port) + " "; - } - cmd += shellEscape(image) + " /bin/sh"; - - const bool ok = runCommand(cmd, false); - message = ok ? "Interactive container session finished." : "Could not start that container session."; - return ok; -} - -bool DockerManager::startInteractive(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker start -ai " + shellEscape(containerId), false); - message = ok ? "Interactive container session finished." : "Could not start that container interactively."; - return ok; -} - -bool DockerManager::startDetached(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker start " + shellEscape(containerId)); - message = ok ? "Container started in detached mode." : "Could not start that container."; - return ok; -} - -bool DockerManager::deleteImage(const std::string& imageId, std::string& message) const { - const bool ok = runCommand("docker rmi " + shellEscape(imageId)); - message = ok ? "Image deleted." : "Could not delete that image. It may still be in use."; - return ok; -} - -bool DockerManager::stopContainer(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker stop " + shellEscape(containerId)); - message = ok ? "Container stopped." : "Could not stop that container."; - return ok; -} - -bool DockerManager::removeContainer(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker rm " + shellEscape(containerId)); - message = ok ? "Container removed." : "Could not remove that container."; - return ok; -} - -bool DockerManager::execShell(const std::string& containerId, std::string& message) const { - const bool ok = runCommand("docker exec -it " + shellEscape(containerId) + " /bin/sh", false); - message = ok ? "Shell session finished." : "Could not open a shell in that container."; - return ok; -} - -bool DockerManager::execDetachedCommand(const std::string& containerId, - const std::string& command, - std::string& message) const { - if (command.empty()) { - message = "Please provide a command to run."; - return false; - } - - const std::string cmd = "docker exec -d " + shellEscape(containerId) + " /bin/sh -c " + - shellEscape(command); - const bool ok = runCommand(cmd); - message = ok ? "Command dispatched in detached mode." : "Could not run that command."; - return ok; -} - -bool DockerManager::spinUpMySQL(const std::string& port, - const std::string& password, - const std::string& version, - std::string& message) const { - const std::string cmd = "docker run -p " + shellEscape(port) + - " --name mysql-container -e MYSQL_ROOT_PASSWORD=" + - shellEscape(password) + " -d " + shellEscape("mysql:" + version); - const bool ok = runCommand(cmd); - message = ok ? "MySQL container launched." : "Could not launch MySQL. Check the version and port mapping."; - return ok; -} - -bool DockerManager::showContainerIP(const std::string& containerId, std::string& message) const { - const std::string command = "docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' " + - shellEscape(containerId); - - std::array buffer{}; - std::string ip; - FILE* pipe = popen(command.c_str(), "r"); - if (!pipe) { - message = "Could not inspect that container."; - return false; - } - - if (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - ip = buffer.data(); - } - pclose(pipe); - - while (!ip.empty() && (ip.back() == '\n' || ip.back() == '\r' || ip.back() == ' ' || ip.back() == '\t')) { - ip.pop_back(); - } - - if (ip.empty()) { - message = "No IP address found. The container may be stopped."; - return false; - } - - message = "Container IP address: " + ip; - return true; -} - -bool DockerManager::createDockerfile(const std::string& baseImage, - const std::string& bashScriptPath, - std::string outputFile, - const std::string& imageName, - std::string& message) const { - if (baseImage.empty() || bashScriptPath.empty()) { - message = "Base image and script path are required."; - return false; - } - - if (!std::filesystem::exists(bashScriptPath)) { - message = "Could not find that script file."; - return false; - } - - if (outputFile.empty()) { - outputFile = "Dockerfile"; - } - - std::ifstream scriptFile(bashScriptPath); - std::ofstream dockerfile(outputFile); - - if (!scriptFile.is_open() || !dockerfile.is_open()) { - message = "Could not open one of the files."; - return false; - } - - dockerfile << "FROM " << baseImage << "\n"; - dockerfile << "WORKDIR /app\n\n"; - dockerfile << "# Auto-generated by Tux-Dock\n"; - - std::string line; - while (std::getline(scriptFile, line)) { - if (line.empty()) { - continue; - } - - if (line.rfind("#", 0) == 0 || line.rfind("#!", 0) == 0) { - continue; - } - - dockerfile << "RUN " << line << "\n"; - } - - dockerfile << "\nCMD [\"/bin/bash\"]\n"; - dockerfile.close(); - scriptFile.close(); - - if (imageName.empty()) { - message = "Dockerfile created. Build skipped because no image name was provided."; - return true; - } - - const bool ok = runCommand("docker build -t " + shellEscape(imageName) + " -f " + - shellEscape(outputFile) + " ."); - message = ok ? "Dockerfile created and image build completed." : "Dockerfile created, but the image build failed."; - return ok; -} - class TuxDockApp { public: void Run(); private: enum class ModalMode { None, Input, Confirm, Select, Message }; - - struct RunContainerContext { - std::string image; - int port_count = 0; - std::vector ports; - }; - - struct MySqlContext { - std::string port; - std::string password; - std::string version; - }; - - struct DockerfileContext { - std::string base_image; - std::string script_path; - std::string output_file; - std::string image_name; - }; + struct RunContainerContext { std::string image; int port_count = 0; std::vector ports; }; + struct MySqlContext { std::string port; std::string password; std::string version; }; + struct DockerfileContext { std::string base_image; std::string script_path; std::string output_file; std::string image_name; }; DockerManager docker_; - - std::vector menu_entries_ = { - "Pull Docker Image", - "Run/Create Interactive Container", - "List All Containers", - "List All Images", - "Start Container Interactively (boot new session)", - "Start Detached Container Session", - "Delete Docker Image", - "Stop Container", - "Remove Container", - "Attach Shell to Running Container", - "Run Detached Command in Container", - "Spin Up MySQL Container", - "Get Container IP Address", - "Create Dockerfile & Build Image from Bash Script", - "About Tux-Dock", - "Exit", - }; - + std::vector containers_; + std::vector images_; + std::vector menu_entries_ = {"Pull Docker Image", "Run/Create Interactive Container", "List All Containers", "List All Images", "Start Container Interactively (boot new session)", "Start Detached Container Session", "Delete Docker Image", "Stop Container", "Remove Container", "Attach Shell to Running Container", "Run Detached Command in Container", "Spin Up MySQL Container", "Create Dockerfile & Build Image from Bash Script", "About Tux-Dock", "Exit"}; int menu_selected_ = 0; std::string status_ = "Ready. Select an action and press Enter."; - ModalMode modal_mode_ = ModalMode::None; - std::string modal_title_; - std::string modal_text_; - std::string modal_input_; + std::string modal_title_, modal_text_, modal_input_; + ftxui::Element modal_content_; std::vector modal_select_entries_; int modal_select_index_ = 0; - ftxui::Component menu_component_ = ftxui::Menu(&menu_entries_, &menu_selected_); ftxui::Component input_component_ = ftxui::Input(&modal_input_, "Type here"); ftxui::Component select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - std::function input_callback_; std::function confirm_callback_; std::function select_callback_; - ftxui::ScreenInteractive* screen_ = nullptr; + std::thread refresh_thread_; + std::vector action_threads_; static bool IsDigits(const std::string& value); static std::string ShortId(const std::string& id); static bool IsValidPortMapping(const std::string& mapping); - - std::string FormatContainerList(const std::vector& containers) const; - std::string FormatImageList(const std::vector>& images) const; - - void SetStatus(const std::string& message); - void OpenInput(const std::string& title, - const std::string& text, - std::function callback, - bool secret = false); - void OpenConfirm(const std::string& title, const std::string& text, std::function callback); - void OpenSelect(const std::string& title, - const std::string& text, - std::vector options, - std::function callback); + ftxui::Element FormatContainerList(const std::vector& containers) const; + ftxui::Element FormatImageList(const std::vector& images) const; + void SetStatus(const std::string& message) { status_ = message; } + void OpenInput(const std::string&, const std::string&, std::function, bool secret = false); + void OpenConfirm(const std::string&, const std::string&, std::function); + void OpenSelect(const std::string&, const std::string&, std::vector, std::function); void OpenMessage(const std::string& title, const std::string& text); - void ResolveInput(bool confirmed); - void ResolveConfirm(bool confirmed); - void ResolveSelect(bool confirmed); - void CloseMessage(); - + void OpenListMessage(const std::string& title, ftxui::Element content); + void ResolveInput(bool); void ResolveConfirm(bool); void ResolveSelect(bool); void CloseMessage(); void ExecuteSelectedAction(); - void ActionPullImage(); - void ActionRunContainer(); - void ActionListContainers(); - void ActionListImages(); - void ActionStartInteractive(); - void ActionStartDetached(); - void ActionDeleteImage(); - void ActionStopContainer(); - void ActionRemoveContainer(); - void ActionExecShell(); - void ActionExecDetachedCommand(); - void ActionSpinUpMySQL(); - void ActionShowContainerIP(); - void ActionCreateDockerfile(); - void ActionAbout(); - void PromptPortCountAndRun(const std::shared_ptr& context); - void PromptNextPort(const std::shared_ptr& context, int index); - - void PromptContainerSelection(const std::string& title, - std::function callback); - void PromptImageSelection(const std::string& title, - std::function callback); - void RunDeferredStatusAction(const std::string& wait_message, - std::function action); + void ActionPullImage(); void ActionRunContainer(); void ActionListContainers(); void ActionListImages(); + void ActionStartInteractive(); void ActionStartDetached(); void ActionDeleteImage(); void ActionStopContainer(); + void ActionRemoveContainer(); void ActionExecShell(); void ActionExecDetachedCommand(); void ActionSpinUpMySQL(); + void ActionCreateDockerfile(); void ActionAbout(); + void PromptPortCountAndRun(const std::shared_ptr&); void PromptNextPort(const std::shared_ptr&, int); + void PromptContainerSelection(const std::string&, std::function); + void PromptImageSelection(const std::string&, std::function); + void RunDeferredStatusAction(const std::string&, std::function); + void RefreshState(const std::string& message = "Refreshing Docker state..."); static void ClearTerminal(); - void RunWithRestoredIO(const std::function& action, - bool clear_before = false, - bool clear_after = false); - - bool OnEvent(ftxui::Event event); - ftxui::Element Render() const; - ftxui::Element RenderModal() const; + void RunWithRestoredIO(const std::function&, bool clear_before = false, bool clear_after = false); + bool OnEvent(ftxui::Event); ftxui::Element Render() const; ftxui::Element RenderModal() const; }; -bool TuxDockApp::IsDigits(const std::string& value) { - if (value.empty()) { - return false; +bool TuxDockApp::IsDigits(const std::string& value) { if (value.empty()) return false; for (unsigned char c : value) if (!std::isdigit(c)) return false; return true; } +std::string TuxDockApp::ShortId(const std::string& id) { return id.size() <= 12 ? id : id.substr(0, 12); } +bool TuxDockApp::IsValidPortMapping(const std::string& mapping) { const auto p = mapping.find(':'); return p != std::string::npos && IsDigits(mapping.substr(0, p)) && IsDigits(mapping.substr(p + 1)); } +ftxui::Element TuxDockApp::FormatContainerList(const std::vector& items) const { + using namespace ftxui; + Elements rows; + for (std::size_t i = 0; i < items.size(); ++i) { + const auto& container = items[i]; + rows.push_back(vbox(Elements{ + text("[" + std::to_string(i + 1) + "] " + container.name) | bold, + text((container.running ? "RUNNING" : "STOPPED") + std::string(" id ") + ShortId(container.id)) | dim, + text("ports: " + (container.ports.empty() ? std::string("none") : container.ports)) | dim, + })); + if (i + 1 < items.size()) rows.push_back(separator()); } - for (unsigned char c : value) { - if (!std::isdigit(c)) { - return false; + return vbox(std::move(rows)); +} + +ftxui::Element TuxDockApp::FormatImageList(const std::vector& items) const { + using namespace ftxui; + Elements rows; + for (std::size_t i = 0; i < items.size(); ++i) { + const auto& image = items[i]; + rows.push_back(vbox(Elements{ + text("[" + std::to_string(i + 1) + "] " + image.second) | bold, + text("id " + ShortId(image.first)) | dim, + })); + if (i + 1 < items.size()) rows.push_back(separator()); + } + return vbox(std::move(rows)); +} + +void TuxDockApp::RefreshState(const std::string& message) { + SetStatus(message); + if (refresh_thread_.joinable()) refresh_thread_.join(); + auto* active = screen_; + refresh_thread_ = std::thread([this, active] { + auto containers = docker_.getContainerList(); + auto images = docker_.getImageList(); + if (active == nullptr || ftxui::ScreenInteractive::Active() != active) return; + active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable { containers_ = std::move(containers); images_ = std::move(images); SetStatus("Docker state refreshed."); }); + active->PostEvent(ftxui::Event::Custom); + }); +} + +void TuxDockApp::OpenInput(const std::string& title, const std::string& text, std::function callback, bool secret) { modal_mode_ = ModalMode::Input; modal_title_ = title; modal_text_ = text; modal_input_.clear(); ftxui::InputOption option; option.password = secret; input_component_ = ftxui::Input(&modal_input_, "Type here", option); input_callback_ = std::move(callback); } +void TuxDockApp::OpenConfirm(const std::string& title, const std::string& text, std::function callback) { modal_mode_ = ModalMode::Confirm; modal_title_ = title; modal_text_ = text; confirm_callback_ = std::move(callback); } +void TuxDockApp::OpenSelect(const std::string& title, const std::string& text, std::vector options, std::function callback) { modal_mode_ = ModalMode::Select; modal_title_ = title; modal_text_ = text; modal_select_entries_ = std::move(options); modal_select_index_ = 0; select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); select_callback_ = std::move(callback); } +void TuxDockApp::OpenMessage(const std::string& title, const std::string& text) { modal_mode_ = ModalMode::Message; modal_title_ = title; modal_text_ = text; modal_content_ = {}; } +void TuxDockApp::OpenListMessage(const std::string& title, ftxui::Element content) { modal_mode_ = ModalMode::Message; modal_title_ = title; modal_text_.clear(); modal_content_ = std::move(content); } +void TuxDockApp::ResolveInput(bool confirmed) { auto callback = std::move(input_callback_); const auto value = modal_input_; modal_mode_ = ModalMode::None; input_callback_ = {}; if (callback) callback(confirmed, value); } +void TuxDockApp::ResolveConfirm(bool confirmed) { auto callback = std::move(confirm_callback_); modal_mode_ = ModalMode::None; confirm_callback_ = {}; if (callback) callback(confirmed); } +void TuxDockApp::ResolveSelect(bool confirmed) { auto callback = std::move(select_callback_); const int selected = modal_select_index_; modal_mode_ = ModalMode::None; select_callback_ = {}; if (callback) callback(confirmed, selected); } +void TuxDockApp::CloseMessage() { modal_mode_ = ModalMode::None; modal_content_ = {}; } + +void TuxDockApp::PromptContainerSelection(const std::string& title, std::function callback) { if (containers_.empty()) { RefreshState("No cached containers. Refreshing..."); return; } std::vector options; for (const auto& c : containers_) options.push_back(c.name + " (" + ShortId(c.id) + ") [" + (c.running ? "running" : "stopped") + "]"); OpenSelect(title, "Select a container with arrows and press Enter.", std::move(options), [this, callback = std::move(callback)](bool ok, int selected) { if (!ok) return SetStatus("Action cancelled."); if (selected < 0 || selected >= static_cast(containers_.size())) return SetStatus("Please choose a valid container."); const auto& c = containers_[static_cast(selected)]; callback(c.id, c.name); }); } +void TuxDockApp::PromptImageSelection(const std::string& title, std::function callback) { if (images_.empty()) { RefreshState("No cached images. Refreshing..."); return; } std::vector options; for (const auto& image : images_) options.push_back(image.second + " (" + ShortId(image.first) + ")"); OpenSelect(title, "Select an image with arrows and press Enter.", std::move(options), [this, callback = std::move(callback)](bool ok, int selected) { if (!ok) return SetStatus("Action cancelled."); if (selected < 0 || selected >= static_cast(images_.size())) return SetStatus("Please choose a valid image."); const auto& image = images_[static_cast(selected)]; callback(image.first, image.second); }); } + +void TuxDockApp::RunDeferredStatusAction(const std::string& wait, std::function action) { + SetStatus(wait); + auto* active = screen_; + action_threads_.emplace_back([this, active, action = std::move(action)]() mutable { + const auto message = action(); + if (active && ftxui::ScreenInteractive::Active() == active) { + active->Post([this, message] { SetStatus(message); RefreshState(); }); + active->PostEvent(ftxui::Event::Custom); } - } - return true; -} - -std::string TuxDockApp::ShortId(const std::string& id) { - if (id.size() <= 12) { - return id; - } - return id.substr(0, 12); -} - -bool TuxDockApp::IsValidPortMapping(const std::string& mapping) { - const std::size_t separator = mapping.find(':'); - if (separator == std::string::npos) { - return false; - } - const std::string host = mapping.substr(0, separator); - const std::string container = mapping.substr(separator + 1); - return IsDigits(host) && IsDigits(container); -} - -std::string TuxDockApp::FormatContainerList( - const std::vector& containers) const { - std::stringstream ss; - for (std::size_t i = 0; i < containers.size(); ++i) { - const auto& container = containers[i]; - const std::string state = container.running ? "running" : "stopped"; - const std::string ports = container.ports.empty() ? "none" : container.ports; - ss << i + 1 << ". " << container.name << " (" << ShortId(container.id) << ")" - << " [" << state << "]" - << " ports: " << ports; - if (i + 1 < containers.size()) { - ss << "\n"; - } - } - return ss.str(); -} - -std::string TuxDockApp::FormatImageList(const std::vector>& images) const { - std::stringstream ss; - for (std::size_t i = 0; i < images.size(); ++i) { - ss << i + 1 << ". " << images[i].second << " (" << ShortId(images[i].first) << ")"; - if (i + 1 < images.size()) { - ss << "\n"; - } - } - return ss.str(); -} - -void TuxDockApp::SetStatus(const std::string& message) { - status_ = message; -} - -void TuxDockApp::OpenInput(const std::string& title, - const std::string& text, - std::function callback, - bool secret) { - modal_mode_ = ModalMode::Input; - modal_title_ = title; - modal_text_ = text; - modal_input_.clear(); - ftxui::InputOption input_option; - input_option.password = secret; - input_component_ = ftxui::Input(&modal_input_, "Type here", input_option); - input_callback_ = std::move(callback); -} - -void TuxDockApp::OpenConfirm(const std::string& title, - const std::string& text, - std::function callback) { - modal_mode_ = ModalMode::Confirm; - modal_title_ = title; - modal_text_ = text; - confirm_callback_ = std::move(callback); -} - -void TuxDockApp::OpenSelect(const std::string& title, - const std::string& text, - std::vector options, - std::function callback) { - modal_mode_ = ModalMode::Select; - modal_title_ = title; - modal_text_ = text; - modal_select_entries_ = std::move(options); - modal_select_index_ = 0; - select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - select_callback_ = std::move(callback); -} - -void TuxDockApp::OpenMessage(const std::string& title, const std::string& text) { - modal_mode_ = ModalMode::Message; - modal_title_ = title; - modal_text_ = text; -} - -void TuxDockApp::ResolveInput(bool confirmed) { - const std::string value = modal_input_; - auto callback = std::move(input_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - modal_input_.clear(); - input_component_ = ftxui::Input(&modal_input_, "Type here"); - input_callback_ = {}; - - if (callback) { - callback(confirmed, value); - } -} - -void TuxDockApp::ResolveConfirm(bool confirmed) { - auto callback = std::move(confirm_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - confirm_callback_ = {}; - - if (callback) { - callback(confirmed); - } -} - -void TuxDockApp::ResolveSelect(bool confirmed) { - const int selected = modal_select_index_; - auto callback = std::move(select_callback_); - - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); - modal_select_entries_.clear(); - modal_select_index_ = 0; - select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); - select_callback_ = {}; - - if (callback) { - callback(confirmed, selected); - } -} - -void TuxDockApp::CloseMessage() { - modal_mode_ = ModalMode::None; - modal_title_.clear(); - modal_text_.clear(); -} - -void TuxDockApp::PromptContainerSelection( - const std::string& title, - std::function callback) { - auto containers = docker_.getContainerList(); - if (containers.empty()) { - SetStatus("No containers available."); - return; - } - - std::vector options; - options.reserve(containers.size()); - for (const auto& container : containers) { - const std::string state = container.running ? "running" : "stopped"; - const std::string ports = container.ports.empty() ? "none" : container.ports; - options.push_back(container.name + " (" + ShortId(container.id) + ") [" + state + "] ports: " + ports); - } - - OpenSelect(title, - "Select a container with arrows and press Enter.", - std::move(options), - [this, containers, callback = std::move(callback)](bool confirmed, int selected) mutable { - if (!confirmed) { - SetStatus("Action cancelled."); - return; - } - if (selected < 0 || selected >= static_cast(containers.size())) { - SetStatus("Please choose a valid container."); - return; - } - const auto& chosen = containers[static_cast(selected)]; - callback(chosen.id, chosen.name); - }); -} - -void TuxDockApp::PromptImageSelection(const std::string& title, - std::function callback) { - auto images = docker_.getImageList(); - if (images.empty()) { - SetStatus("No images found."); - return; - } - - std::vector options; - options.reserve(images.size()); - for (const auto& image : images) { - options.push_back(image.second + " (" + ShortId(image.first) + ")"); - } - - OpenSelect(title, - "Select an image with arrows and press Enter.", - std::move(options), - [this, images, callback = std::move(callback)](bool confirmed, int selected) mutable { - if (!confirmed) { - SetStatus("Action cancelled."); - return; - } - if (selected < 0 || selected >= static_cast(images.size())) { - SetStatus("Please choose a valid image."); - return; - } - const auto& chosen = images[static_cast(selected)]; - callback(chosen.first, chosen.second); - }); -} - -void TuxDockApp::ClearTerminal() { - std::cout << "\x1b[2J\x1b[H" << std::flush; -} - -void TuxDockApp::RunWithRestoredIO(const std::function& action, - bool clear_before, - bool clear_after) { - if (screen_ != nullptr) { - screen_->WithRestoredIO([&] { - if (clear_before) { - ClearTerminal(); - } - action(); - if (clear_after) { - ClearTerminal(); - } - })(); - return; - } - if (clear_before) { - ClearTerminal(); - } - action(); - if (clear_after) { - ClearTerminal(); - } -} - -void TuxDockApp::RunDeferredStatusAction(const std::string& wait_message, - std::function action) { - SetStatus(wait_message); - - if (screen_ == nullptr) { - SetStatus(action()); - return; - } - - ftxui::ScreenInteractive* active_screen = screen_; - active_screen->PostEvent(ftxui::Event::Custom); - - std::thread([this, active_screen, action = std::move(action)]() mutable { - const std::string final_message = action(); - if (ftxui::ScreenInteractive::Active() != active_screen) { - return; - } - active_screen->Post([this, final_message] { SetStatus(final_message); }); - active_screen->PostEvent(ftxui::Event::Custom); - }).detach(); -} - -void TuxDockApp::ActionPullImage() { - const std::vector quick_images = { - "debian:stable", - "ubuntu:noble", - "rockylinux:9.3", - "alpine:latest", - }; - - std::vector options = quick_images; - options.push_back("Custom image..."); - - OpenSelect("Pull Docker Image", - "Select an image with arrows, then press Enter.", - std::move(options), - [this, quick_images](bool confirmed, int selected) { - if (!confirmed) { - SetStatus("Image pull cancelled."); - return; - } - - if (selected < 0 || selected > static_cast(quick_images.size())) { - SetStatus("Please select a valid image option."); - return; - } - - if (selected < static_cast(quick_images.size())) { - const std::string image = quick_images[static_cast(selected)]; - RunDeferredStatusAction("Please wait, pulling image...", [this, image] { - std::string message; - docker_.pullImage(image, message); - return message; - }); - return; - } - - OpenInput("Pull Docker Image", - "Enter custom Docker image name:", - [this](bool custom_confirmed, const std::string& image) { - if (!custom_confirmed) { - SetStatus("Image pull cancelled."); - return; - } - if (image.empty()) { - SetStatus("Image name cannot be empty."); - ActionPullImage(); - return; - } - RunDeferredStatusAction("Please wait, pulling image...", [this, image] { - std::string message; - docker_.pullImage(image, message); - return message; - }); - }); - }); -} - -void TuxDockApp::ActionRunContainer() { - auto images = docker_.getImageList(); - std::vector options; - options.reserve(images.size() + 1); - for (const auto& image : images) { - options.push_back(image.second + " (" + ShortId(image.first) + ")"); - } - options.push_back("Custom image..."); - - OpenSelect("Run Interactive Container", - "Select an image with arrows, then press Enter.", - std::move(options), - [this, images](bool confirmed, int selected) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - - if (selected < 0 || selected > static_cast(images.size())) { - SetStatus("Please select a valid image option."); - return; - } - - auto context = std::make_shared(); - if (selected == static_cast(images.size())) { - OpenInput("Run Interactive Container", - "Enter custom image name:", - [this, context](bool custom_confirmed, const std::string& image_name) { - if (!custom_confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (image_name.empty()) { - SetStatus("Image name cannot be empty."); - ActionRunContainer(); - return; - } - context->image = image_name; - PromptPortCountAndRun(context); - }); - return; - } - - context->image = images[static_cast(selected)].second; - PromptPortCountAndRun(context); - }); -} - -void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr& context) { - OpenInput("Port Mappings", - "How many port mappings? (0 for none)", - [this, context](bool confirmed, const std::string& count_value) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (!IsDigits(count_value)) { - SetStatus("Please enter a valid number."); - PromptPortCountAndRun(context); - return; - } - - context->port_count = std::stoi(count_value); - context->ports.clear(); - if (context->port_count < 0) { - SetStatus("Port mappings cannot be negative."); - PromptPortCountAndRun(context); - return; - } - - PromptNextPort(context, 0); - }); -} - -void TuxDockApp::PromptNextPort(const std::shared_ptr& context, int index) { - if (index >= context->port_count) { - std::string message; - SetStatus("Starting interactive container..."); - RunWithRestoredIO([this, context, &message] { - docker_.runContainerInteractive(context->image, context->ports, message); - }, true, true); - SetStatus(message); - return; - } - - OpenInput("Port Mapping", - "Enter mapping #" + std::to_string(index + 1) + " (example: 8080:80)", - [this, context, index](bool confirmed, const std::string& mapping) { - if (!confirmed) { - SetStatus("Container run cancelled."); - return; - } - if (!IsValidPortMapping(mapping)) { - SetStatus("Use host:container format, for example 8080:80."); - PromptNextPort(context, index); - return; - } - - context->ports.push_back(mapping); - PromptNextPort(context, index + 1); - }); + }); } +void TuxDockApp::ClearTerminal() { std::cout << "\x1b[2J\x1b[H" << std::flush; } +void TuxDockApp::RunWithRestoredIO(const std::function& action, bool before, bool after) { if (screen_) screen_->WithRestoredIO([&] { if (before) ClearTerminal(); action(); if (after) ClearTerminal(); })(); else action(); } void TuxDockApp::ActionListContainers() { - auto containers = docker_.getContainerList(); - if (containers.empty()) { - SetStatus("No containers available."); + if (containers_.empty()) { + OpenMessage("Containers", "No containers found."); return; } - OpenMessage("Containers", FormatContainerList(containers)); - SetStatus("Container list opened."); + OpenListMessage("Containers", FormatContainerList(containers_)); } void TuxDockApp::ActionListImages() { - auto images = docker_.getImageList(); - if (images.empty()) { - SetStatus("No images found."); + if (images_.empty()) { + OpenMessage("Images", "No images found."); return; } - OpenMessage("Images", FormatImageList(images)); - SetStatus("Image list opened."); + OpenListMessage("Images", FormatImageList(images_)); } - -void TuxDockApp::ActionStartInteractive() { - PromptContainerSelection("Start Interactively", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Starting interactive session..."); - RunWithRestoredIO([this, &id, &message] { docker_.startInteractive(id, message); }, true, true); - SetStatus(message); - }); -} - -void TuxDockApp::ActionStartDetached() { - PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Starting container in detached mode..."); - RunWithRestoredIO([this, &id, &message] { docker_.startDetached(id, message); }); - SetStatus(message); - }); -} - -void TuxDockApp::ActionDeleteImage() { - PromptImageSelection("Delete Image", [this](const std::string& id, const std::string& tag) { - OpenConfirm("Delete Image", - "Delete image " + tag + "?", - [this, id](bool confirmed) { - if (!confirmed) { - SetStatus("Image deletion cancelled."); - return; - } - RunDeferredStatusAction("Please wait, deleting image...", [this, id] { - std::string message; - docker_.deleteImage(id, message); - return message; - }); +void TuxDockApp::ActionPullImage() { OpenInput("Pull Docker Image", "Enter image name:", [this](bool ok, const std::string& image) { if (!ok) return; RunDeferredStatusAction("Please wait, pulling image...", [this, image] { std::string m; docker_.pullImage(image, m); return m; }); }); } +void TuxDockApp::ActionRunContainer() { PromptImageSelection("Run Interactive Container", [this](const std::string&, const std::string& tag) { auto context = std::make_shared(); context->image = tag; PromptPortCountAndRun(context); }); } +void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr& c) { OpenInput("Port Mappings", "How many port mappings? (0 for none)", [this, c](bool ok, const std::string& value) { if (!ok) return; if (!IsDigits(value)) return SetStatus("Please enter a valid number."); c->port_count = std::stoi(value); PromptNextPort(c, 0); }); } +void TuxDockApp::PromptNextPort(const std::shared_ptr& c, int index) { if (index >= c->port_count) { std::string m; RunWithRestoredIO([this, c, &m] { docker_.runContainerInteractive(c->image, c->ports, m); }, true, true); SetStatus(m); RefreshState(); return; } OpenInput("Port Mapping", "Enter mapping #" + std::to_string(index + 1), [this, c, index](bool ok, const std::string& value) { if (!ok) return; if (!IsValidPortMapping(value)) return SetStatus("Use host:container format."); c->ports.push_back(value); PromptNextPort(c, index + 1); }); } +void TuxDockApp::ActionStartInteractive() { PromptContainerSelection("Start Interactively", [this](const std::string& id, const std::string&) { std::string m; RunWithRestoredIO([this, &id, &m] { docker_.startInteractive(id, m); }, true, true); SetStatus(m); RefreshState(); }); } +void TuxDockApp::ActionStartDetached() { PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { RunDeferredStatusAction("Starting container...", [this, id] { std::string m; docker_.startDetached(id, m); return m; }); }); } +void TuxDockApp::ActionDeleteImage() { PromptImageSelection("Delete Image", [this](const std::string& id, const std::string& tag) { OpenConfirm("Delete Image", "Delete image " + tag + "?", [this, id](bool ok) { if (ok) RunDeferredStatusAction("Deleting image...", [this, id] { std::string m; docker_.deleteImage(id, m); return m; }); }); }); } +void TuxDockApp::ActionStopContainer() { PromptContainerSelection("Stop Container", [this](const std::string& id, const std::string&) { RunDeferredStatusAction("Stopping container...", [this, id] { std::string m; docker_.stopContainer(id, m); return m; }); }); } +void TuxDockApp::ActionRemoveContainer() { PromptContainerSelection("Remove Container", [this](const std::string& id, const std::string& name) { OpenConfirm("Remove Container", "Remove container " + name + "?", [this, id](bool ok) { if (ok) RunDeferredStatusAction("Removing container...", [this, id] { std::string m; docker_.removeContainer(id, m); return m; }); }); }); } +void TuxDockApp::ActionExecShell() { PromptContainerSelection("Open Shell", [this](const std::string& id, const std::string&) { std::string m; RunWithRestoredIO([this, &id, &m] { docker_.execShell(id, m); }, true, true); SetStatus(m); }); } +void TuxDockApp::ActionExecDetachedCommand() { PromptContainerSelection("Run Detached Command", [this](const std::string& id, const std::string& name) { OpenInput("Detached Command", "Enter command to run in " + name + ":", [this, id](bool ok, const std::string& command) { if (!ok) return; RunDeferredStatusAction("Running command...", [this, id, command] { std::string m; docker_.execDetachedCommand(id, command, m); return m; }); }); }); } +void TuxDockApp::ActionSpinUpMySQL() { OpenInput("MySQL Setup", "Enter port mapping:", [this](bool ok, const std::string& port) { if (!ok || !IsValidPortMapping(port)) return SetStatus("Use host:container format."); OpenInput("MySQL Setup", "Enter root password:", [this, port](bool ok2, const std::string& password) { if (!ok2) return; OpenInput("MySQL Setup", "Enter version tag:", [this, port, password](bool ok3, const std::string& version) { if (!ok3) return; RunDeferredStatusAction("Launching MySQL...", [this, port, password, version] { std::string m; docker_.spinUpMySQL(port, password, version, m); return m; }); }); }, true); }); } +void TuxDockApp::ActionCreateDockerfile() { + OpenInput("Dockerfile Builder", "Enter base image:", [this](bool ok, const std::string& base) { + if (!ok) return; + OpenInput("Dockerfile Builder", "Enter bash script path:", [this, base](bool ok2, const std::string& script) { + if (!ok2) return; + OpenInput("Dockerfile Builder", "Enter output Dockerfile:", [this, base, script](bool ok3, const std::string& output) { + if (!ok3) return; + OpenInput("Dockerfile Builder", "Enter image name (empty skips build):", [this, base, script, output](bool ok4, const std::string& image) { + if (!ok4) return; + RunDeferredStatusAction("Creating Dockerfile...", [this, base, script, output, image] { + std::string m; + docker_.createDockerfile(base, script, output, image, m); + return m; }); - }); -} - -void TuxDockApp::ActionStopContainer() { - PromptContainerSelection("Stop Container", [this](const std::string& id, const std::string&) { - RunDeferredStatusAction("Please wait, stopping container...", [this, id] { - std::string message; - docker_.stopContainer(id, message); - return message; + }); + }); }); }); } +void TuxDockApp::ActionAbout() { SetStatus("Tux-Dock 022526-dev\nCreated by markmental"); } -void TuxDockApp::ActionRemoveContainer() { - PromptContainerSelection("Remove Container", [this](const std::string& id, const std::string& name) { - OpenConfirm("Remove Container", - "Remove container " + name + "?", - [this, id](bool confirmed) { - if (!confirmed) { - SetStatus("Container removal cancelled."); - return; - } - RunDeferredStatusAction("Please wait, removing container...", [this, id] { - std::string message; - docker_.removeContainer(id, message); - return message; - }); - }); - }); -} - -void TuxDockApp::ActionExecShell() { - PromptContainerSelection("Open Shell", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Opening shell session..."); - RunWithRestoredIO([this, &id, &message] { docker_.execShell(id, message); }, true, true); - SetStatus(message); - }); -} - -void TuxDockApp::ActionExecDetachedCommand() { - PromptContainerSelection("Run Detached Command", - [this](const std::string& id, const std::string& name) { - OpenInput("Detached Command", - "Enter command to run in " + name + ":", - [this, id](bool confirmed, const std::string& command) { - if (!confirmed) { - SetStatus("Detached command cancelled."); - return; - } - std::string message; - SetStatus("Running detached command..."); - RunWithRestoredIO([this, &id, &command, &message] { - docker_.execDetachedCommand(id, command, message); - }); - SetStatus(message); - }); - }); -} - -void TuxDockApp::ActionSpinUpMySQL() { - auto context = std::make_shared(); - - OpenInput("MySQL Setup", - "Enter port mapping (example: 3306:3306)", - [this, context](bool confirmed, const std::string& port) { - if (!confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (!IsValidPortMapping(port)) { - SetStatus("Use host:container format, for example 3306:3306."); - return; - } - context->port = port; - - OpenInput("MySQL Setup", - "Enter MySQL root password:", - [this, context](bool pwd_confirmed, const std::string& password) { - if (!pwd_confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (password.empty()) { - SetStatus("Password cannot be empty."); - return; - } - context->password = password; - - OpenInput("MySQL Setup", - "Enter MySQL version tag (example: 8)", - [this, context](bool ver_confirmed, const std::string& version) { - if (!ver_confirmed) { - SetStatus("MySQL setup cancelled."); - return; - } - if (version.empty()) { - SetStatus("Version tag cannot be empty."); - return; - } - context->version = version; - - std::string message; - SetStatus("Launching MySQL container..."); - RunWithRestoredIO([this, context, &message] { - docker_.spinUpMySQL(context->port, - context->password, - context->version, - message); - }); - SetStatus(message); - }); - }, - true); - }); -} - -void TuxDockApp::ActionShowContainerIP() { - PromptContainerSelection("Container IP", [this](const std::string& id, const std::string&) { - std::string message; - SetStatus("Looking up container IP..."); - RunWithRestoredIO([this, &id, &message] { docker_.showContainerIP(id, message); }); - SetStatus(message); - }); -} - -void TuxDockApp::ActionCreateDockerfile() { - auto context = std::make_shared(); - - OpenInput("Dockerfile Builder", - "Enter base image (example: ubuntu:22.04)", - [this, context](bool base_confirmed, const std::string& base) { - if (!base_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - if (base.empty()) { - SetStatus("Base image cannot be empty."); - return; - } - context->base_image = base; - - OpenInput("Dockerfile Builder", - "Enter bash script path:", - [this, context](bool script_confirmed, const std::string& script_path) { - if (!script_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - if (script_path.empty()) { - SetStatus("Script path cannot be empty."); - return; - } - context->script_path = script_path; - - OpenInput("Dockerfile Builder", - "Enter output Dockerfile name (leave empty for Dockerfile)", - [this, context](bool output_confirmed, const std::string& output_file) { - if (!output_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - context->output_file = output_file; - - OpenInput("Dockerfile Builder", - "Enter image name to build (leave empty to skip build)", - [this, context](bool image_confirmed, - const std::string& image_name) { - if (!image_confirmed) { - SetStatus("Dockerfile workflow cancelled."); - return; - } - context->image_name = image_name; - - std::string message; - SetStatus("Creating Dockerfile and running build..."); - RunWithRestoredIO([this, context, &message] { - docker_.createDockerfile(context->base_image, - context->script_path, - context->output_file, - context->image_name, - message); - }); - SetStatus(message); - }); - }); - }); - }); -} - -void TuxDockApp::ActionAbout() { - SetStatus("Tux-Dock 022526-dev\n" - "Created by markmental\n\n" - "GitHub:\n" - "https://github.com/MARKMENTAL/tuxdock\n\n" - "Forgejo:\n" - "https://mentalnet.xyz/forgejo/markmental/tuxdock"); -} - -void TuxDockApp::ExecuteSelectedAction() { - switch (menu_selected_) { - case 0: - ActionPullImage(); - break; - case 1: - ActionRunContainer(); - break; - case 2: - ActionListContainers(); - break; - case 3: - ActionListImages(); - break; - case 4: - ActionStartInteractive(); - break; - case 5: - ActionStartDetached(); - break; - case 6: - ActionDeleteImage(); - break; - case 7: - ActionStopContainer(); - break; - case 8: - ActionRemoveContainer(); - break; - case 9: - ActionExecShell(); - break; - case 10: - ActionExecDetachedCommand(); - break; - case 11: - ActionSpinUpMySQL(); - break; - case 12: - ActionShowContainerIP(); - break; - case 13: - ActionCreateDockerfile(); - break; - case 14: - ActionAbout(); - break; - case 15: - SetStatus("Exiting Tux-Dock."); - if (screen_ != nullptr) { - screen_->ExitLoopClosure()(); - } - break; - default: - SetStatus("Please choose a valid menu option."); - break; - } -} - -bool TuxDockApp::OnEvent(ftxui::Event event) { - if (modal_mode_ == ModalMode::Input) { - if (event == ftxui::Event::Return) { - ResolveInput(true); - return true; - } - if (event == ftxui::Event::Escape) { - ResolveInput(false); - return true; - } - return input_component_->OnEvent(event); - } - - if (modal_mode_ == ModalMode::Confirm) { - if (event == ftxui::Event::Character("y") || event == ftxui::Event::Character("Y") || - event == ftxui::Event::Return) { - ResolveConfirm(true); - return true; - } - if (event == ftxui::Event::Character("n") || event == ftxui::Event::Character("N") || - event == ftxui::Event::Escape) { - ResolveConfirm(false); - return true; - } - return true; - } - - if (modal_mode_ == ModalMode::Select) { - if (event == ftxui::Event::Return) { - ResolveSelect(true); - return true; - } - if (event == ftxui::Event::Escape) { - ResolveSelect(false); - return true; - } - return select_component_->OnEvent(event); - } - - if (modal_mode_ == ModalMode::Message) { - if (event == ftxui::Event::Return || event == ftxui::Event::Escape) { - CloseMessage(); - return true; - } - return true; - } - - if (event == ftxui::Event::Return) { - ExecuteSelectedAction(); - return true; - } - - return false; -} +void TuxDockApp::ExecuteSelectedAction() { switch (menu_selected_) { case 0: ActionPullImage(); break; case 1: ActionRunContainer(); break; case 2: ActionListContainers(); break; case 3: ActionListImages(); break; case 4: ActionStartInteractive(); break; case 5: ActionStartDetached(); break; case 6: ActionDeleteImage(); break; case 7: ActionStopContainer(); break; case 8: ActionRemoveContainer(); break; case 9: ActionExecShell(); break; case 10: ActionExecDetachedCommand(); break; case 11: ActionSpinUpMySQL(); break; case 12: ActionCreateDockerfile(); break; case 13: ActionAbout(); break; case 14: if (screen_) screen_->ExitLoopClosure()(); break; default: break; } } +bool TuxDockApp::OnEvent(ftxui::Event event) { if (modal_mode_ == ModalMode::Input) { if (event == ftxui::Event::Return) { ResolveInput(true); return true; } if (event == ftxui::Event::Escape) { ResolveInput(false); return true; } return input_component_->OnEvent(event); } if (modal_mode_ == ModalMode::Confirm) { if (event == ftxui::Event::Return || event == ftxui::Event::Character("y")) { ResolveConfirm(true); return true; } if (event == ftxui::Event::Escape || event == ftxui::Event::Character("n")) { ResolveConfirm(false); return true; } return true; } if (modal_mode_ == ModalMode::Select) { if (event == ftxui::Event::Return) { ResolveSelect(true); return true; } if (event == ftxui::Event::Escape) { ResolveSelect(false); return true; } return select_component_->OnEvent(event); } if (modal_mode_ == ModalMode::Message) { if (event == ftxui::Event::Return || event == ftxui::Event::Escape) { CloseMessage(); return true; } return true; } if (event == ftxui::Event::Return) { ExecuteSelectedAction(); return true; } return false; } ftxui::Element TuxDockApp::RenderModal() const { using namespace ftxui; - Element body; Element footer; - if (modal_mode_ == ModalMode::Input) { - body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), input_component_->Render() | border}); + body = vbox(Elements{paragraph(modal_text_), separator(), input_component_->Render() | border}); footer = text("Enter: confirm Esc: cancel") | dim; } else if (modal_mode_ == ModalMode::Confirm) { body = paragraph(modal_text_); footer = text("Y/Enter: confirm N/Esc: cancel") | dim; } else if (modal_mode_ == ModalMode::Select) { - body = vbox(ftxui::Elements{paragraph(modal_text_), separator(), select_component_->Render() | frame | vscroll_indicator}); + body = vbox(Elements{paragraph(modal_text_), separator(), select_component_->Render() | frame | vscroll_indicator}); footer = text("Up/Down: choose Enter: confirm Esc: cancel") | dim; } else { - body = paragraph(modal_text_) | vscroll_indicator | frame | size(HEIGHT, LESS_THAN, 14); + body = (modal_content_ ? std::move(modal_content_) : paragraph(modal_text_)) | + frame | vscroll_indicator | + size(HEIGHT, LESS_THAN, 18) | size(WIDTH, LESS_THAN, 96); footer = text("Enter/Esc: close") | dim; } - - return window(text(modal_title_), vbox(ftxui::Elements{body, separator(), footer})) | - size(WIDTH, GREATER_THAN, 70) | size(HEIGHT, GREATER_THAN, 12) | center; + return window(text(modal_title_), vbox(Elements{body, separator(), footer})) | + size(WIDTH, LESS_THAN, 96) | size(WIDTH, GREATER_THAN, 42) | + size(HEIGHT, LESS_THAN, 24) | size(HEIGHT, GREATER_THAN, 10) | center; } - -ftxui::Element TuxDockApp::Render() const { - using namespace ftxui; - - Elements status_lines; - { - std::stringstream ss(status_); - std::string line; - while (std::getline(ss, line)) { - status_lines.push_back(line.empty() ? text(" ") : text(line)); - } - if (status_lines.empty()) { - status_lines.push_back(text(" ")); - } - } - - auto actions_panel = window(text("Actions"), - vbox(ftxui::Elements{menu_component_->Render() | frame | vscroll_indicator, - separator(), - text("Up/Down: navigate Enter: select") | dim})) | - size(WIDTH, GREATER_THAN, 48) | flex; - - auto status_panel = window(text("Status"), vbox(ftxui::Elements{vbox(std::move(status_lines)) | yflex | frame | vscroll_indicator, - separator(), - text("High-level updates only") | dim})) | - size(WIDTH, GREATER_THAN, 48) | flex; - - Element base = hbox(ftxui::Elements{actions_panel, separator(), status_panel}) | border; - - if (modal_mode_ == ModalMode::None) { - return base; - } - - return dbox({base, RenderModal() | clear_under | center}); -} - +ftxui::Element TuxDockApp::Render() const { using namespace ftxui; Elements lines; std::stringstream s(status_); std::string line; while (std::getline(s, line)) lines.push_back(line.empty() ? text(" ") : text(line)); auto base = hbox(Elements{window(text("Actions"), vbox(Elements{menu_component_->Render() | frame | vscroll_indicator, separator(), text("Up/Down: navigate Enter: select") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex, separator(), window(text("Status"), vbox(Elements{vbox(std::move(lines)) | yflex | frame | vscroll_indicator, separator(), text("Engine API cache") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex}) | border; return modal_mode_ == ModalMode::None ? base : dbox({base, RenderModal() | clear_under | center}); } void TuxDockApp::Run() { + RefreshState("Connecting to Docker..."); auto root = ftxui::Renderer(menu_component_, [this] { return Render(); }); - auto app = ftxui::CatchEvent(root, [this](ftxui::Event event) { return OnEvent(event); }); - + auto app = ftxui::CatchEvent(root, [this](ftxui::Event e) { return OnEvent(e); }); auto screen = ftxui::ScreenInteractive::TerminalOutput(); screen_ = &screen; screen.Loop(app); screen_ = nullptr; + if (refresh_thread_.joinable()) refresh_thread_.join(); + for (auto& thread : action_threads_) if (thread.joinable()) thread.join(); } - -int main() { - TuxDockApp app; - app.Run(); - return 0; -} +int main() { TuxDockApp app; app.Run(); return 0; } diff --git a/src/docker_engine_client.cpp b/src/docker_engine_client.cpp new file mode 100644 index 0000000..d1c949d --- /dev/null +++ b/src/docker_engine_client.cpp @@ -0,0 +1,93 @@ +#include "docker_engine_client.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +DockerEngineClient::DockerEngineClient(std::string socket_path) + : socket_path_(std::move(socket_path)) {} + +EngineResponse DockerEngineClient::request(const std::string& method, + const std::string& path, + const std::string& body) const { + EngineResponse response; + const int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == -1) { + response.error = std::strerror(errno); + return response; + } + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (socket_path_.size() >= sizeof(address.sun_path)) { + response.error = "Docker socket path is too long."; + close(fd); + return response; + } + std::strncpy(address.sun_path, socket_path_.c_str(), sizeof(address.sun_path) - 1); + if (connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { + response.error = std::strerror(errno); + close(fd); + return response; + } + + timeval timeout{}; + timeout.tv_sec = 10; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + std::ostringstream request; + request << method << " " << path << " HTTP/1.1\r\n" + << "Host: docker\r\n" + << "Connection: close\r\n" + << "Content-Type: application/json\r\n" + << "Content-Length: " << body.size() << "\r\n\r\n" + << body; + const std::string wire = request.str(); + std::size_t sent = 0; + while (sent < wire.size()) { + const ssize_t count = write(fd, wire.data() + sent, wire.size() - sent); + if (count <= 0) { + response.error = std::strerror(errno); + close(fd); + return response; + } + sent += static_cast(count); + } + + std::string raw; + char buffer[8192]; + ssize_t count = 0; + while ((count = read(fd, buffer, sizeof(buffer))) > 0) { + raw.append(buffer, static_cast(count)); + } + close(fd); + if (count < 0) { + response.error = std::strerror(errno); + return response; + } + + const std::size_t header_end = raw.find("\r\n\r\n"); + if (header_end == std::string::npos) { + response.error = "Invalid response from Docker Engine."; + return response; + } + const std::size_t status_end = raw.find("\r\n"); + std::istringstream status_line(raw.substr(0, status_end)); + std::string http_version; + status_line >> http_version >> response.status_code; + if (response.status_code == 0) { + response.error = "Invalid HTTP status from Docker Engine."; + return response; + } + response.body = raw.substr(header_end + 4); + if (!response.ok()) { + response.error = response.body.empty() ? "Docker Engine request failed." : response.body; + } + return response; +} diff --git a/src/docker_engine_client.hpp b/src/docker_engine_client.hpp new file mode 100644 index 0000000..e9781b3 --- /dev/null +++ b/src/docker_engine_client.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +struct EngineResponse { + int status_code = 0; + std::string body; + std::string error; + + bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); } +}; + +class DockerEngineClient { +public: + explicit DockerEngineClient(std::string socket_path = "/var/run/docker.sock"); + + EngineResponse request(const std::string& method, + const std::string& path, + const std::string& body = {}) const; + +private: + std::string socket_path_; +}; diff --git a/src/docker_manager.cpp b/src/docker_manager.cpp new file mode 100644 index 0000000..e2d0469 --- /dev/null +++ b/src/docker_manager.cpp @@ -0,0 +1,234 @@ +#include "docker_manager.hpp" + +#include "process_runner.hpp" + +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace { + +std::string joinPorts(const json& ports) { + std::ostringstream result; + bool first = true; + for (const auto& port : ports) { + const auto public_port = port.value("PublicPort", 0); + const auto private_port = port.value("PrivatePort", 0); + if (public_port == 0 || private_port == 0) continue; + if (!first) result << ", "; + result << public_port << ":" << private_port; + first = false; + } + return result.str(); +} + +std::string apiError(const EngineResponse& response, const std::string& fallback) { + if (!response.error.empty()) return response.error; + return fallback; +} + +} // namespace + +std::string DockerManager::processError(const std::string& fallback, + const std::string& stderr_text) { + return stderr_text.empty() ? fallback : stderr_text; +} + +bool DockerManager::runProcess(const std::vector& args, + std::string& message, + bool inherit_stdio) { + ProcessOptions options; + options.inherit_stdio = inherit_stdio; + options.capture_stdout = !inherit_stdio; + options.capture_stderr = !inherit_stdio; + const ProcessResult result = ProcessRunner::run(args, options); + if (!result.ok()) { + message = processError("Docker command failed.", result.stderr_text); + return false; + } + return true; +} + +std::vector DockerManager::getContainerList() const { + std::vector containers; + const EngineResponse response = engine_.request("GET", "/containers/json?all=true"); + if (!response.ok()) return containers; + + try { + for (const auto& item : json::parse(response.body)) { + ContainerInfo info; + info.id = item.value("Id", ""); + info.name = item.value("Names", std::vector{}).empty() + ? "" + : item.value("Names", std::vector{}).front(); + if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1); + info.status = item.value("Status", ""); + info.running = item.value("State", "") == "running"; + info.ports = joinPorts(item.value("Ports", json::array())); + if (!info.id.empty() && !info.name.empty()) containers.push_back(std::move(info)); + } + } catch (const json::exception&) { + return {}; + } + return containers; +} + +std::vector DockerManager::getImageList() const { + std::vector images; + const EngineResponse response = engine_.request("GET", "/images/json"); + if (!response.ok()) return images; + + try { + for (const auto& item : json::parse(response.body)) { + const std::string id = item.value("Id", ""); + const auto tags = item.value("RepoTags", std::vector{}); + if (id.empty()) continue; + if (tags.empty()) images.emplace_back(id, ""); + else for (const auto& tag : tags) images.emplace_back(id, tag); + } + } catch (const json::exception&) { + return {}; + } + return images; +} + +bool DockerManager::pullImage(const std::string& image, std::string& message) const { + if (image.empty()) { + message = "Please provide an image name."; + return false; + } + const bool ok = runProcess({"docker", "pull", image}, message); + if (ok) message = "Image pulled successfully."; + return ok; +} + +bool DockerManager::runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const { + if (image.empty()) { + message = "Please choose an image first."; + return false; + } + std::vector args{"docker", "run", "-it"}; + for (const auto& port : ports) { + args.push_back("-p"); + args.push_back(port); + } + args.insert(args.end(), {image, "/bin/sh"}); + const bool ok = runProcess(args, message, true); + if (ok) message = "Interactive container session finished."; + return ok; +} + +bool DockerManager::startInteractive(const std::string& id, std::string& message) const { + const bool ok = runProcess({"docker", "start", "-ai", id}, message, true); + if (ok) message = "Interactive container session finished."; + return ok; +} + +bool DockerManager::startDetached(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("POST", "/containers/" + id + "/start"); + if (!response.ok()) { + message = apiError(response, "Could not start that container."); + return false; + } + message = "Container started in detached mode."; + return true; +} + +bool DockerManager::deleteImage(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("DELETE", "/images/" + id); + if (!response.ok()) { + message = apiError(response, "Could not delete that image."); + return false; + } + message = "Image deleted."; + return true; +} + +bool DockerManager::stopContainer(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("POST", "/containers/" + id + "/stop"); + if (!response.ok()) { + message = apiError(response, "Could not stop that container."); + return false; + } + message = "Container stopped."; + return true; +} + +bool DockerManager::removeContainer(const std::string& id, std::string& message) const { + const EngineResponse response = engine_.request("DELETE", "/containers/" + id); + if (!response.ok()) { + message = apiError(response, "Could not remove that container."); + return false; + } + message = "Container removed."; + return true; +} + +bool DockerManager::execShell(const std::string& id, std::string& message) const { + const bool ok = runProcess({"docker", "exec", "-it", id, "/bin/sh"}, message, true); + if (ok) message = "Shell session finished."; + return ok; +} + +bool DockerManager::execDetachedCommand(const std::string& id, + const std::string& command, + std::string& message) const { + if (command.empty()) { + message = "Please provide a command to run."; + return false; + } + const bool ok = runProcess({"docker", "exec", "-d", id, "/bin/sh", "-c", command}, message); + if (ok) message = "Command dispatched in detached mode."; + return ok; +} + +bool DockerManager::spinUpMySQL(const std::string& port, + const std::string& password, + const std::string& version, + std::string& message) const { + const bool ok = runProcess({"docker", "run", "-p", port, "--name", "mysql-container", + "-e", "MYSQL_ROOT_PASSWORD=" + password, "-d", "mysql:" + version}, message); + if (ok) message = "MySQL container launched."; + return ok; +} + +bool DockerManager::createDockerfile(const std::string& baseImage, + const std::string& bashScriptPath, + std::string outputFile, + const std::string& imageName, + std::string& message) const { + if (baseImage.empty() || bashScriptPath.empty()) { + message = "Base image and script path are required."; + return false; + } + if (!std::filesystem::exists(bashScriptPath)) { + message = "Could not find that script file."; + return false; + } + if (outputFile.empty()) outputFile = "Dockerfile"; + std::ifstream scriptFile(bashScriptPath); + std::ofstream dockerfile(outputFile); + if (!scriptFile.is_open() || !dockerfile.is_open()) { + message = "Could not open one of the files."; + return false; + } + dockerfile << "FROM " << baseImage << "\nWORKDIR /app\n\n# Auto-generated by Tux-Dock\n"; + std::string line; + while (std::getline(scriptFile, line)) { + if (!line.empty() && line.rfind("#", 0) != 0) dockerfile << "RUN " << line << "\n"; + } + dockerfile << "\nCMD [\"/bin/bash\"]\n"; + if (imageName.empty()) { + message = "Dockerfile created. Build skipped because no image name was provided."; + return true; + } + const bool ok = runProcess({"docker", "build", "-t", imageName, "-f", outputFile, "."}, message); + if (ok) message = "Dockerfile created and image build completed."; + return ok; +} diff --git a/src/docker_manager.hpp b/src/docker_manager.hpp new file mode 100644 index 0000000..9fcd137 --- /dev/null +++ b/src/docker_manager.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "docker_engine_client.hpp" + +#include +#include +#include + +class DockerManager { +public: + struct ContainerInfo { + std::string id; + std::string name; + std::string status; + std::string ports; + bool running = false; + }; + + using ImageInfo = std::pair; + + std::vector getContainerList() const; + std::vector getImageList() const; + + bool pullImage(const std::string& image, std::string& message) const; + bool runContainerInteractive(const std::string& image, + const std::vector& ports, + std::string& message) const; + bool startInteractive(const std::string& containerId, std::string& message) const; + bool startDetached(const std::string& containerId, std::string& message) const; + bool deleteImage(const std::string& imageId, std::string& message) const; + bool stopContainer(const std::string& containerId, std::string& message) const; + bool removeContainer(const std::string& containerId, std::string& message) const; + bool execShell(const std::string& containerId, std::string& message) const; + bool execDetachedCommand(const std::string& containerId, + const std::string& command, + std::string& message) const; + bool spinUpMySQL(const std::string& port, + const std::string& password, + const std::string& version, + std::string& message) const; + bool createDockerfile(const std::string& baseImage, + const std::string& bashScriptPath, + std::string outputFile, + const std::string& imageName, + std::string& message) const; + +private: + DockerEngineClient engine_; + + static std::string processError(const std::string& fallback, + const std::string& stderr_text); + static bool runProcess(const std::vector& args, + std::string& message, + bool inherit_stdio = false); +}; diff --git a/src/process_runner.cpp b/src/process_runner.cpp new file mode 100644 index 0000000..41fd2bd --- /dev/null +++ b/src/process_runner.cpp @@ -0,0 +1,122 @@ +#include "process_runner.hpp" + +#include +#include +#include +#include + +#include +#include +namespace { + +struct Pipe { + int read = -1; + int write = -1; + + ~Pipe() { + if (read != -1) close(read); + if (write != -1) close(write); + } +}; + +} // namespace + +ProcessResult ProcessRunner::run(const std::vector& args, + const ProcessOptions& options) { + ProcessResult result; + if (args.empty()) { + result.stderr_text = "No executable specified."; + return result; + } + + Pipe stdout_pipe; + Pipe stderr_pipe; + if (!options.inherit_stdio && options.capture_stdout) { + int fds[2]; + if (pipe(fds) != 0) { + result.stderr_text = std::strerror(errno); + return result; + } + stdout_pipe.read = fds[0]; + stdout_pipe.write = fds[1]; + } + if (!options.inherit_stdio && options.capture_stderr) { + int fds[2]; + if (pipe(fds) != 0) { + result.stderr_text = std::strerror(errno); + return result; + } + stderr_pipe.read = fds[0]; + stderr_pipe.write = fds[1]; + } + + pid_t pid = fork(); + if (pid == -1) { + result.stderr_text = std::strerror(errno); + return result; + } + + if (pid == 0) { + if (!options.inherit_stdio && options.capture_stdout) { + if (dup2(stdout_pipe.write, STDOUT_FILENO) == -1) _exit(127); + } + if (!options.inherit_stdio && options.capture_stderr) { + if (dup2(stderr_pipe.write, STDERR_FILENO) == -1) _exit(127); + } + if (stdout_pipe.read != -1) close(stdout_pipe.read); + if (stdout_pipe.write != -1) close(stdout_pipe.write); + if (stderr_pipe.read != -1) close(stderr_pipe.read); + if (stderr_pipe.write != -1) close(stderr_pipe.write); + + std::vector argv; + argv.reserve(args.size() + 1); + for (const auto& arg : args) argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(127); + } + + if (stdout_pipe.write != -1) close(stdout_pipe.write); + stdout_pipe.write = -1; + if (stderr_pipe.write != -1) close(stderr_pipe.write); + stderr_pipe.write = -1; + std::array buffer{}; + pollfd poll_fds[2]{}; + int active = 0; + if (stdout_pipe.read != -1) poll_fds[active++] = {stdout_pipe.read, POLLIN, 0}; + if (stderr_pipe.read != -1) poll_fds[active++] = {stderr_pipe.read, POLLIN, 0}; + while (active > 0) { + if (poll(poll_fds, static_cast(active), -1) == -1) { + if (errno == EINTR) continue; + break; + } + for (int i = 0; i < active;) { + if ((poll_fds[i].revents & (POLLIN | POLLHUP)) == 0) { + ++i; + continue; + } + const ssize_t count = read(poll_fds[i].fd, buffer.data(), buffer.size()); + if (count > 0) { + if (poll_fds[i].fd == stdout_pipe.read) result.stdout_text.append(buffer.data(), static_cast(count)); + else result.stderr_text.append(buffer.data(), static_cast(count)); + ++i; + } else { + close(poll_fds[i].fd); + poll_fds[i] = poll_fds[--active]; + } + } + } + + int status = 0; + if (waitpid(pid, &status, 0) == -1) { + result.stderr_text = std::strerror(errno); + return result; + } + if (WIFEXITED(status)) { + result.exit_code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + result.signaled = true; + result.signal = WTERMSIG(status); + } + return result; +} diff --git a/src/process_runner.hpp b/src/process_runner.hpp new file mode 100644 index 0000000..fece031 --- /dev/null +++ b/src/process_runner.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct ProcessResult { + int exit_code = -1; + bool signaled = false; + int signal = 0; + std::string stdout_text; + std::string stderr_text; + + bool ok() const { return !signaled && exit_code == 0; } +}; + +struct ProcessOptions { + bool capture_stdout = true; + bool capture_stderr = true; + bool inherit_stdio = false; +}; + +class ProcessRunner { +public: + static ProcessResult run(const std::vector& args, + const ProcessOptions& options = {}); +};