1164 lines
42 KiB
C++
1164 lines
42 KiB
C++
#include "src/docker_manager.hpp"
|
|
#include "src/docker_state_poller.hpp"
|
|
#include "src/operation_state.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <ftxui/component/component.hpp>
|
|
#include <ftxui/component/screen_interactive.hpp>
|
|
#include <ftxui/dom/elements.hpp>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
namespace {
|
|
|
|
volatile std::sig_atomic_t g_sigint_busy = 0;
|
|
volatile std::sig_atomic_t g_sigint_trapped = 0;
|
|
volatile std::sig_atomic_t g_sigtstp_trapped = 0;
|
|
bool g_sigint_trap_installed = false;
|
|
bool g_sigtstp_trap_installed = false;
|
|
struct sigaction g_sigint_previous {};
|
|
struct sigaction g_sigtstp_previous {};
|
|
|
|
void TuxDockHandleSigint(int signal_number) {
|
|
if (g_sigint_busy != 0) {
|
|
++g_sigint_trapped;
|
|
return;
|
|
}
|
|
if (g_sigint_trap_installed) {
|
|
const auto previous_handler = g_sigint_previous.sa_handler;
|
|
if (previous_handler != nullptr && previous_handler != SIG_DFL) {
|
|
if (previous_handler != SIG_IGN) previous_handler(signal_number);
|
|
return;
|
|
}
|
|
}
|
|
std::signal(signal_number, SIG_DFL);
|
|
std::raise(signal_number);
|
|
}
|
|
|
|
void TuxDockHandleSigtstp(int signal_number) {
|
|
if (g_sigint_busy != 0) {
|
|
++g_sigtstp_trapped;
|
|
return;
|
|
}
|
|
if (g_sigtstp_trap_installed) {
|
|
const auto previous_handler = g_sigtstp_previous.sa_handler;
|
|
if (previous_handler != nullptr && previous_handler != SIG_DFL) {
|
|
if (previous_handler != SIG_IGN) previous_handler(signal_number);
|
|
return;
|
|
}
|
|
}
|
|
std::signal(signal_number, SIG_DFL);
|
|
std::raise(signal_number);
|
|
}
|
|
|
|
void InstallSignalTraps() {
|
|
g_sigint_trapped = 0;
|
|
g_sigtstp_trapped = 0;
|
|
g_sigint_busy = 1;
|
|
if (!g_sigint_trap_installed) {
|
|
struct sigaction trap {};
|
|
sigemptyset(&trap.sa_mask);
|
|
trap.sa_flags = SA_RESTART;
|
|
trap.sa_handler = TuxDockHandleSigint;
|
|
if (sigaction(SIGINT, &trap, &g_sigint_previous) == 0) {
|
|
g_sigint_trap_installed = true;
|
|
}
|
|
}
|
|
if (!g_sigtstp_trap_installed) {
|
|
struct sigaction trap {};
|
|
sigemptyset(&trap.sa_mask);
|
|
trap.sa_flags = SA_RESTART;
|
|
trap.sa_handler = TuxDockHandleSigtstp;
|
|
if (sigaction(SIGTSTP, &trap, &g_sigtstp_previous) == 0) {
|
|
g_sigtstp_trap_installed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void RemoveSignalTraps() {
|
|
g_sigint_busy = 0;
|
|
g_sigint_trapped = 0;
|
|
g_sigtstp_trapped = 0;
|
|
if (g_sigint_trap_installed) {
|
|
sigaction(SIGINT, &g_sigint_previous, nullptr);
|
|
g_sigint_trap_installed = false;
|
|
}
|
|
if (g_sigtstp_trap_installed) {
|
|
sigaction(SIGTSTP, &g_sigtstp_previous, nullptr);
|
|
g_sigtstp_trap_installed = false;
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace {
|
|
|
|
using nlohmann::json;
|
|
|
|
struct DetachedHistoryEntry {
|
|
std::string command;
|
|
std::string timestamp;
|
|
std::string container;
|
|
};
|
|
|
|
constexpr std::size_t kMaxDetachedHistoryEntries = 200;
|
|
|
|
std::filesystem::path GetDetachedHistoryPath() {
|
|
const char* home = std::getenv("HOME");
|
|
if (!home || !*home) return {};
|
|
return std::filesystem::path(home) / ".tuxdock" / "detached-history.json";
|
|
}
|
|
|
|
std::string CurrentTimestamp() {
|
|
const auto now = std::chrono::system_clock::now();
|
|
const auto time = std::chrono::system_clock::to_time_t(now);
|
|
std::tm tm {};
|
|
localtime_r(&time, &tm);
|
|
std::ostringstream oss;
|
|
oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
|
|
return oss.str();
|
|
}
|
|
|
|
std::vector<DetachedHistoryEntry> LoadDetachedHistory() {
|
|
const auto path = GetDetachedHistoryPath();
|
|
if (path.empty() || !std::filesystem::exists(path)) return {};
|
|
|
|
std::ifstream file(path);
|
|
if (!file) return {};
|
|
|
|
try {
|
|
const auto data = json::parse(file);
|
|
std::vector<DetachedHistoryEntry> result;
|
|
for (const auto& item : data) {
|
|
DetachedHistoryEntry entry;
|
|
entry.command = item.value("command", "");
|
|
entry.timestamp = item.value("timestamp", "");
|
|
entry.container = item.value("container", "");
|
|
if (!entry.command.empty()) result.push_back(std::move(entry));
|
|
}
|
|
return result;
|
|
} catch (...) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
void SaveDetachedHistory(const std::vector<DetachedHistoryEntry>& entries) {
|
|
const auto path = GetDetachedHistoryPath();
|
|
if (path.empty()) return;
|
|
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(path.parent_path(), ec);
|
|
|
|
std::ofstream file(path);
|
|
if (!file) return;
|
|
|
|
try {
|
|
json data = json::array();
|
|
for (const auto& entry : entries) {
|
|
data.push_back({
|
|
{"command", entry.command},
|
|
{"timestamp", entry.timestamp},
|
|
{"container", entry.container},
|
|
});
|
|
}
|
|
file << data.dump(2);
|
|
} catch (...) {
|
|
}
|
|
}
|
|
|
|
void AddDetachedHistoryEntry(const std::string& container,
|
|
const std::string& command) {
|
|
if (command.empty()) return;
|
|
|
|
auto entries = LoadDetachedHistory();
|
|
entries.insert(entries.begin(), DetachedHistoryEntry{
|
|
command,
|
|
CurrentTimestamp(),
|
|
container,
|
|
});
|
|
|
|
std::size_t count = 0;
|
|
const auto end = std::remove_if(entries.begin(), entries.end(),
|
|
[&](const DetachedHistoryEntry& entry) {
|
|
if (entry.container != container) return false;
|
|
return ++count > kMaxDetachedHistoryEntries;
|
|
});
|
|
entries.erase(end, entries.end());
|
|
SaveDetachedHistory(entries);
|
|
}
|
|
|
|
void ClearDetachedHistory(const std::string& container) {
|
|
auto entries = LoadDetachedHistory();
|
|
const auto end = std::remove_if(entries.begin(), entries.end(),
|
|
[&](const DetachedHistoryEntry& entry) {
|
|
return entry.container == container;
|
|
});
|
|
entries.erase(end, entries.end());
|
|
SaveDetachedHistory(entries);
|
|
}
|
|
|
|
std::vector<DetachedHistoryEntry> LoadDetachedHistoryForContainer(
|
|
const std::string& container) {
|
|
const auto all = LoadDetachedHistory();
|
|
std::vector<DetachedHistoryEntry> result;
|
|
for (const auto& entry : all) {
|
|
if (entry.container == container) result.push_back(entry);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
class TuxDockApp {
|
|
public:
|
|
int Run();
|
|
|
|
private:
|
|
enum class ModalMode { None, Input, Confirm, Select, Message, Busy, History };
|
|
struct RunContainerContext {
|
|
std::string name;
|
|
std::string image;
|
|
int port_count = 0;
|
|
std::vector<std::string> ports;
|
|
};
|
|
|
|
DockerManager docker_;
|
|
std::vector<DockerManager::ContainerInfo> containers_;
|
|
std::vector<DockerManager::ImageInfo> images_;
|
|
std::vector<std::string> menu_entries_ = {
|
|
"Pull Docker Image",
|
|
"Create Container",
|
|
"List All Containers",
|
|
"List All Images",
|
|
"Start Detached Container Session",
|
|
"Delete Docker Image",
|
|
"Stop Container",
|
|
"Remove Container",
|
|
"Attach Shell to Running Container",
|
|
"Run Detached Command in Container",
|
|
"Run Command in Container (with output)",
|
|
"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_;
|
|
ftxui::Element modal_content_;
|
|
std::vector<std::string> 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::vector<std::string> modal_history_display_;
|
|
std::vector<DetachedHistoryEntry> modal_history_entries_;
|
|
int modal_history_index_ = 0;
|
|
ftxui::Component history_component_ = ftxui::Menu(&modal_history_display_, &modal_history_index_);
|
|
std::string history_container_id_;
|
|
std::string history_container_name_;
|
|
std::function<void(bool, const std::string&)> input_callback_;
|
|
std::function<void(bool)> confirm_callback_;
|
|
std::function<void(bool, int)> select_callback_;
|
|
ftxui::ScreenInteractive* screen_ = nullptr;
|
|
std::thread refresh_thread_;
|
|
std::thread spinner_thread_;
|
|
std::atomic<bool> spinner_stop_{true};
|
|
std::vector<std::thread> action_threads_;
|
|
OperationState operation_state_;
|
|
DockerStatePoller state_poller_;
|
|
std::size_t spinner_frame_ = 0;
|
|
static constexpr int kMaxStopTimeout = 300;
|
|
|
|
static bool IsDigits(const std::string& value);
|
|
static bool IsValidContainerName(const std::string& name);
|
|
static std::string ShortId(const std::string& id);
|
|
static bool IsValidPortMapping(const std::string& mapping);
|
|
ftxui::Element FormatContainerList(const std::vector<DockerManager::ContainerInfo>& containers) const;
|
|
ftxui::Element FormatImageList(const std::vector<DockerManager::ImageInfo>& images) const;
|
|
void SetStatus(const std::string& message) { status_ = message; }
|
|
void OpenInput(const std::string&, const std::string&,
|
|
std::function<void(bool, const std::string&)>, bool secret = false,
|
|
const std::string& initial_value = "");
|
|
void OpenConfirm(const std::string&, const std::string&, std::function<void(bool)>);
|
|
void OpenSelect(const std::string&, const std::string&, std::vector<std::string>,
|
|
std::function<void(bool, int)>);
|
|
void OpenMessage(const std::string& title, const std::string& text);
|
|
void OpenListMessage(const std::string& title, ftxui::Element content);
|
|
void OpenHistory();
|
|
void ResolveInput(bool);
|
|
void ResolveConfirm(bool);
|
|
void ResolveSelect(bool);
|
|
void ResolveHistory();
|
|
void CloseMessage();
|
|
void ExecuteSelectedAction();
|
|
void ActionPullImage();
|
|
void ActionRunContainer();
|
|
void ActionListContainers();
|
|
void ActionListImages();
|
|
void ActionStartDetached();
|
|
void ActionDeleteImage();
|
|
void ActionStopContainer();
|
|
void ActionRemoveContainer();
|
|
void ActionExecShell();
|
|
void ActionExecDetachedCommand();
|
|
void OpenDetachedCommandInput(const std::string& initial_value = "");
|
|
void ActionExecCommandWithOutput();
|
|
void OpenCommandWithOutputInput(const std::string& initial_value = "");
|
|
void ActionAbout();
|
|
void PromptPortCountAndRun(const std::shared_ptr<RunContainerContext>&);
|
|
void PromptNextPort(const std::shared_ptr<RunContainerContext>&, int);
|
|
void PromptContainerSelection(
|
|
const std::string&,
|
|
std::function<void(const std::string&, const std::string&)>);
|
|
void PromptImageSelection(
|
|
const std::string&,
|
|
std::function<void(const std::string&, const std::string&)>);
|
|
void RunDeferredStatusAction(const std::string&, std::function<std::string()>);
|
|
void BeginBusyOperation(const std::string&, const std::string&, std::function<std::string()>,
|
|
std::function<void(const std::string&)> on_complete = nullptr);
|
|
void StartSpinner();
|
|
void StopSpinner();
|
|
void BeginStopOperation(const std::string& id, int timeout_seconds = 10);
|
|
void RefreshState(const std::string& message = "Refreshing Docker state...");
|
|
void ApplyRefreshResults(DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
|
DockerManager::ListResult<DockerManager::ImageInfo> images,
|
|
bool silent = false);
|
|
static void ClearTerminal();
|
|
void RunWithRestoredIO(const std::function<void()>&, 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;
|
|
for (unsigned char c : value) {
|
|
if (!std::isdigit(c)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool TuxDockApp::IsValidContainerName(const std::string& name) {
|
|
if (name.empty()) return false;
|
|
for (unsigned char c : name) {
|
|
if (!(std::isalnum(c) || c == '_' || c == '.' || 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 separator = mapping.find(':');
|
|
return separator != std::string::npos &&
|
|
IsDigits(mapping.substr(0, separator)) &&
|
|
IsDigits(mapping.substr(separator + 1));
|
|
}
|
|
ftxui::Element TuxDockApp::FormatContainerList(const std::vector<DockerManager::ContainerInfo>& 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());
|
|
}
|
|
return vbox(std::move(rows));
|
|
}
|
|
|
|
ftxui::Element TuxDockApp::FormatImageList(const std::vector<DockerManager::ImageInfo>& 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 {
|
|
ApplyRefreshResults(std::move(containers), std::move(images));
|
|
});
|
|
active->PostEvent(ftxui::Event::Custom);
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::ApplyRefreshResults(
|
|
DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
|
DockerManager::ListResult<DockerManager::ImageInfo> images,
|
|
bool silent) {
|
|
std::string error;
|
|
if (containers.ok()) {
|
|
containers_ = std::move(containers.items);
|
|
} else {
|
|
error = "Containers: " + containers.error;
|
|
}
|
|
if (images.ok()) {
|
|
images_ = std::move(images.items);
|
|
} else {
|
|
if (!error.empty()) error += "\n";
|
|
error += "Images: " + images.error;
|
|
}
|
|
if (!silent) {
|
|
SetStatus(error.empty() ? "Docker state refreshed." : "Refresh failed; cached state preserved.\n" + error);
|
|
}
|
|
}
|
|
|
|
void TuxDockApp::OpenInput(const std::string& title,
|
|
const std::string& text,
|
|
std::function<void(bool, const std::string&)> callback,
|
|
bool secret,
|
|
const std::string& initial_value) {
|
|
modal_mode_ = ModalMode::Input;
|
|
modal_title_ = title;
|
|
modal_text_ = text;
|
|
modal_input_ = initial_value;
|
|
|
|
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<void(bool)> 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<std::string> options,
|
|
std::function<void(bool, int)> 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<void(const std::string&, const std::string&)> callback) {
|
|
if (containers_.empty()) {
|
|
RefreshState("No cached containers. Refreshing...");
|
|
return;
|
|
}
|
|
|
|
std::vector<std::string> options;
|
|
for (const auto& container : containers_) {
|
|
options.push_back(container.name + " (" + ShortId(container.id) + ") [" +
|
|
(container.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<int>(containers_.size())) {
|
|
return SetStatus("Please choose a valid container.");
|
|
}
|
|
const auto& container = containers_[static_cast<std::size_t>(selected)];
|
|
callback(container.id, container.name);
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::PromptImageSelection(
|
|
const std::string& title,
|
|
std::function<void(const std::string&, const std::string&)> callback) {
|
|
if (images_.empty()) {
|
|
RefreshState("No cached images. Refreshing...");
|
|
return;
|
|
}
|
|
|
|
std::vector<std::string> 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<int>(images_.size())) {
|
|
return SetStatus("Please choose a valid image.");
|
|
}
|
|
const auto& image = images_[static_cast<std::size_t>(selected)];
|
|
callback(image.first, image.second);
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::RunDeferredStatusAction(const std::string& wait, std::function<std::string()> action) {
|
|
BeginBusyOperation("Working", wait, std::move(action));
|
|
}
|
|
|
|
void TuxDockApp::StartSpinner() {
|
|
StopSpinner();
|
|
spinner_stop_ = false;
|
|
auto* active = screen_;
|
|
spinner_thread_ = std::thread([this, active] {
|
|
while (active && !spinner_stop_.load() && ftxui::ScreenInteractive::Active() == active) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
if (spinner_stop_.load() || ftxui::ScreenInteractive::Active() != active) break;
|
|
active->RequestAnimationFrame();
|
|
active->Post([this] { if (!spinner_stop_.load()) ++spinner_frame_; });
|
|
}
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::StopSpinner() {
|
|
spinner_stop_ = true;
|
|
if (spinner_thread_.joinable()) spinner_thread_.join();
|
|
}
|
|
|
|
void TuxDockApp::BeginBusyOperation(const std::string& title,
|
|
const std::string& message,
|
|
std::function<std::string()> action,
|
|
std::function<void(const std::string&)> on_complete) {
|
|
operation_state_.begin(title, message);
|
|
modal_mode_ = ModalMode::Busy;
|
|
spinner_frame_ = 0;
|
|
InstallSignalTraps();
|
|
auto* active = screen_;
|
|
StartSpinner();
|
|
if (active) active->PostEvent(ftxui::Event::Custom);
|
|
action_threads_.emplace_back([this, active, action = std::move(action), on_complete = std::move(on_complete)]() mutable {
|
|
const auto message = action();
|
|
if (active && ftxui::ScreenInteractive::Active() == active) {
|
|
active->Post([this, message, on_complete = std::move(on_complete)] {
|
|
RemoveSignalTraps();
|
|
operation_state_.complete(message);
|
|
StopSpinner();
|
|
modal_mode_ = ModalMode::None;
|
|
if (on_complete) {
|
|
on_complete(message);
|
|
} else {
|
|
OpenMessage("Operation complete", message);
|
|
}
|
|
state_poller_.trigger_now();
|
|
});
|
|
active->PostEvent(ftxui::Event::Custom);
|
|
}
|
|
});
|
|
}
|
|
void TuxDockApp::BeginStopOperation(const std::string& id, int timeout_seconds) {
|
|
operation_state_.begin("Stopping container", "Stopping and refreshing state...");
|
|
modal_mode_ = ModalMode::Busy;
|
|
InstallSignalTraps();
|
|
auto* active = screen_;
|
|
spinner_frame_ = 0;
|
|
StartSpinner();
|
|
action_threads_.emplace_back([this, active, id, timeout_seconds] {
|
|
std::string message;
|
|
const bool stopped = docker_.stopContainer(id, message, timeout_seconds);
|
|
auto containers = docker_.getContainerList();
|
|
auto images = docker_.getImageList();
|
|
if (!active || ftxui::ScreenInteractive::Active() != active) return;
|
|
active->Post([this, stopped, message, containers = std::move(containers), images = std::move(images)]() mutable {
|
|
RemoveSignalTraps();
|
|
ApplyRefreshResults(std::move(containers), std::move(images), /*silent=*/true);
|
|
operation_state_.complete(message);
|
|
StopSpinner();
|
|
modal_mode_ = ModalMode::None;
|
|
OpenMessage(stopped ? "Container stopped" : "Stop failed", message);
|
|
});
|
|
active->PostEvent(ftxui::Event::Custom);
|
|
});
|
|
}
|
|
void TuxDockApp::ClearTerminal() { std::cout << "\x1b[2J\x1b[H" << std::flush; }
|
|
void TuxDockApp::RunWithRestoredIO(const std::function<void()>& action,
|
|
bool before,
|
|
bool after) {
|
|
if (!screen_) {
|
|
action();
|
|
return;
|
|
}
|
|
|
|
screen_->WithRestoredIO([&] {
|
|
if (before) ClearTerminal();
|
|
action();
|
|
if (after) ClearTerminal();
|
|
})();
|
|
}
|
|
|
|
void TuxDockApp::ActionListContainers() {
|
|
if (containers_.empty()) {
|
|
OpenMessage("Containers", "No containers found.");
|
|
return;
|
|
}
|
|
OpenListMessage("Containers", FormatContainerList(containers_));
|
|
}
|
|
|
|
void TuxDockApp::ActionListImages() {
|
|
if (images_.empty()) {
|
|
OpenMessage("Images", "No images found.");
|
|
return;
|
|
}
|
|
OpenListMessage("Images", FormatImageList(images_));
|
|
}
|
|
void TuxDockApp::ActionPullImage() {
|
|
OpenInput(
|
|
"Pull Docker Image", "Enter image name:",
|
|
[this](bool ok, const std::string& image) {
|
|
if (!ok) return;
|
|
BeginBusyOperation(
|
|
"Pulling image", "Please wait...",
|
|
[this, image] {
|
|
std::string message;
|
|
docker_.pullImage(image, message);
|
|
return message;
|
|
});
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::ActionRunContainer() {
|
|
PromptImageSelection(
|
|
"Create Container",
|
|
[this](const std::string&, const std::string& tag) {
|
|
auto context = std::make_shared<RunContainerContext>();
|
|
context->image = tag;
|
|
OpenInput(
|
|
"Container Name", "Enter a name for the container:",
|
|
[this, context](bool ok, const std::string& name) {
|
|
if (!ok) return;
|
|
if (!IsValidContainerName(name)) {
|
|
return SetStatus(
|
|
"Use letters, numbers, '.', '_', or '-' in the container name.");
|
|
}
|
|
context->name = name;
|
|
PromptPortCountAndRun(context);
|
|
});
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::PromptPortCountAndRun(
|
|
const std::shared_ptr<RunContainerContext>& context) {
|
|
OpenInput(
|
|
"Port Mappings", "How many port mappings? (0 for none)",
|
|
[this, context](bool ok, const std::string& value) {
|
|
if (!ok) return;
|
|
if (!IsDigits(value)) return SetStatus("Please enter a valid number.");
|
|
context->port_count = std::stoi(value);
|
|
PromptNextPort(context, 0);
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::PromptNextPort(
|
|
const std::shared_ptr<RunContainerContext>& context,
|
|
int index) {
|
|
if (index >= context->port_count) {
|
|
BeginBusyOperation(
|
|
"Creating container", "Please wait...",
|
|
[this, context] {
|
|
std::string message;
|
|
docker_.createContainer(context->name, context->image,
|
|
context->ports, message);
|
|
return message;
|
|
});
|
|
return;
|
|
}
|
|
|
|
OpenInput(
|
|
"Port Mapping", "Enter host-to-container port mapping #" + std::to_string(index + 1) + ". e.g: 8080:80.",
|
|
[this, context, index](bool ok, const std::string& value) {
|
|
if (!ok) return;
|
|
if (!IsValidPortMapping(value)) {
|
|
return SetStatus("Use host:container format.");
|
|
}
|
|
context->ports.push_back(value);
|
|
PromptNextPort(context, index + 1);
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::ActionStartDetached() {
|
|
PromptContainerSelection(
|
|
"Start Detached",
|
|
[this](const std::string& id, const std::string&) {
|
|
BeginBusyOperation(
|
|
"Starting container", "Please wait...",
|
|
[this, id] {
|
|
std::string message;
|
|
docker_.startDetached(id, message);
|
|
return 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 ok) {
|
|
if (!ok) return;
|
|
BeginBusyOperation(
|
|
"Deleting image", "Please wait...",
|
|
[this, id] {
|
|
std::string message;
|
|
docker_.deleteImage(id, message);
|
|
return message;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::ActionStopContainer() {
|
|
PromptContainerSelection(
|
|
"Stop Container",
|
|
[this](const std::string& id, const std::string& name) {
|
|
OpenInput(
|
|
"Stop Container",
|
|
"Shutdown grace period in seconds for " + name +
|
|
" (default 60, max " + std::to_string(kMaxStopTimeout) + "):",
|
|
[this, id](bool ok, const std::string& value) {
|
|
if (!ok) return;
|
|
int seconds = 60;
|
|
if (!value.empty()) {
|
|
try {
|
|
seconds = std::stoi(value);
|
|
} catch (...) {
|
|
seconds = 60;
|
|
}
|
|
}
|
|
if (seconds < 0) seconds = 60;
|
|
if (seconds > kMaxStopTimeout) seconds = kMaxStopTimeout;
|
|
BeginStopOperation(id, seconds);
|
|
});
|
|
});
|
|
}
|
|
|
|
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) return;
|
|
BeginBusyOperation(
|
|
"Removing container", "Please wait...",
|
|
[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;
|
|
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) {
|
|
history_container_id_ = id;
|
|
history_container_name_ = name;
|
|
OpenDetachedCommandInput();
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::OpenDetachedCommandInput(const std::string& initial_value) {
|
|
OpenInput(
|
|
"Detached Command",
|
|
"Enter command to run in " + history_container_name_ + ": (F1: history)",
|
|
[this](bool ok, const std::string& command) {
|
|
if (!ok) return;
|
|
AddDetachedHistoryEntry(history_container_name_, command);
|
|
BeginBusyOperation(
|
|
"Running command", "Please wait...",
|
|
[this, command] {
|
|
std::string message;
|
|
docker_.execDetachedCommand(history_container_id_, command, message);
|
|
return message;
|
|
});
|
|
},
|
|
/*secret=*/false,
|
|
initial_value);
|
|
}
|
|
|
|
void TuxDockApp::OpenHistory() {
|
|
modal_history_entries_ =
|
|
LoadDetachedHistoryForContainer(history_container_name_);
|
|
modal_history_display_.clear();
|
|
modal_history_display_.push_back("[Enter new command]");
|
|
for (const auto& entry : modal_history_entries_) {
|
|
modal_history_display_.push_back("[" + entry.timestamp + "] " + entry.command);
|
|
}
|
|
modal_history_display_.push_back("[Clear history]");
|
|
modal_history_index_ = 0;
|
|
history_component_ = ftxui::Menu(&modal_history_display_, &modal_history_index_);
|
|
modal_title_ = "Detached Command History - " + history_container_name_;
|
|
modal_mode_ = ModalMode::History;
|
|
}
|
|
|
|
void TuxDockApp::ResolveHistory() {
|
|
const int selected = modal_history_index_;
|
|
modal_mode_ = ModalMode::Input;
|
|
|
|
if (selected == 0) {
|
|
modal_input_.clear();
|
|
return;
|
|
}
|
|
|
|
if (selected == static_cast<int>(modal_history_display_.size()) - 1) {
|
|
ClearDetachedHistory(history_container_name_);
|
|
modal_input_.clear();
|
|
return;
|
|
}
|
|
|
|
const auto& entry = modal_history_entries_[static_cast<std::size_t>(selected) - 1];
|
|
modal_input_ = entry.command;
|
|
}
|
|
|
|
void TuxDockApp::ActionExecCommandWithOutput() {
|
|
PromptContainerSelection(
|
|
"Run Command with Output",
|
|
[this](const std::string& id, const std::string& name) {
|
|
history_container_id_ = id;
|
|
history_container_name_ = name;
|
|
OpenCommandWithOutputInput();
|
|
});
|
|
}
|
|
|
|
void TuxDockApp::OpenCommandWithOutputInput(const std::string& initial_value) {
|
|
OpenInput(
|
|
"Command with Output",
|
|
"Enter command to run in " + history_container_name_ + ": (F1: history)",
|
|
[this](bool ok, const std::string& command) {
|
|
if (!ok) return;
|
|
AddDetachedHistoryEntry(history_container_name_, command);
|
|
BeginBusyOperation(
|
|
"Running command", "Please wait...",
|
|
[this, command] {
|
|
std::string output;
|
|
std::string message;
|
|
const bool success = docker_.execCommandWithOutput(
|
|
history_container_id_, command, output, message);
|
|
return success ? output : (message + "\n" + output);
|
|
},
|
|
[this](const std::string& result) {
|
|
const bool failed = result.find("Command exited with code") == 0 ||
|
|
result.find("Command terminated by signal") == 0;
|
|
OpenListMessage(
|
|
failed ? "Command failed" : "Command output",
|
|
ftxui::vbox({ftxui::paragraph(result)}));
|
|
});
|
|
},
|
|
/*secret=*/false,
|
|
initial_value);
|
|
}
|
|
|
|
void TuxDockApp::ActionAbout() {
|
|
OpenMessage("About Tux-Dock", "Tux-Dock 0.4.2-beta | Created by markmental");
|
|
}
|
|
|
|
void TuxDockApp::ExecuteSelectedAction() {
|
|
switch (menu_selected_) {
|
|
case 0: ActionPullImage(); break;
|
|
case 1: ActionRunContainer(); break;
|
|
case 2: ActionListContainers(); break;
|
|
case 3: ActionListImages(); break;
|
|
case 4: ActionStartDetached(); break;
|
|
case 5: ActionDeleteImage(); break;
|
|
case 6: ActionStopContainer(); break;
|
|
case 7: ActionRemoveContainer(); break;
|
|
case 8: ActionExecShell(); break;
|
|
case 9: ActionExecDetachedCommand(); break;
|
|
case 10: ActionExecCommandWithOutput(); break;
|
|
case 11: ActionAbout(); break;
|
|
case 12:
|
|
if (screen_) screen_->ExitLoopClosure()();
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
bool TuxDockApp::OnEvent(ftxui::Event event) {
|
|
if (modal_mode_ == ModalMode::Busy) {
|
|
if (event == ftxui::Event::Custom) ++spinner_frame_;
|
|
return true;
|
|
}
|
|
if (modal_mode_ == ModalMode::Input) {
|
|
if (event == ftxui::Event::F1) {
|
|
OpenHistory();
|
|
return true;
|
|
}
|
|
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::History) {
|
|
if (event == ftxui::Event::Return) {
|
|
ResolveHistory();
|
|
return true;
|
|
}
|
|
if (event == ftxui::Event::Escape) {
|
|
modal_mode_ = ModalMode::Input;
|
|
return true;
|
|
}
|
|
return history_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;
|
|
}
|
|
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(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(Elements{
|
|
paragraph(modal_text_),
|
|
separator(),
|
|
select_component_->Render() | frame | vscroll_indicator,
|
|
});
|
|
footer = text("Up/Down: choose Enter: confirm Esc: cancel") | dim;
|
|
} else if (modal_mode_ == ModalMode::History) {
|
|
body = vbox(Elements{
|
|
paragraph("Select a previous command for " + history_container_name_ + ":"),
|
|
separator(),
|
|
history_component_->Render() | frame | vscroll_indicator,
|
|
});
|
|
footer = text("Up/Down: choose Enter: select Esc: cancel") | dim;
|
|
} else if (modal_mode_ == ModalMode::Busy) {
|
|
static const std::string spinner = "|/-\\";
|
|
Elements busy_elements = Elements{
|
|
text(operation_state_.message()),
|
|
separator(),
|
|
text(std::string(" ") + spinner[spinner_frame_ % spinner.size()]) | bold,
|
|
};
|
|
if (g_sigint_trapped > 0) {
|
|
busy_elements.push_back(text("Ctrl+C ignored: operation in progress") | dim);
|
|
}
|
|
if (g_sigtstp_trapped > 0) {
|
|
busy_elements.push_back(text("Ctrl+Z ignored: operation in progress") | dim);
|
|
}
|
|
body = vbox(std::move(busy_elements));
|
|
footer = text("Please wait; input is disabled") | dim;
|
|
} else {
|
|
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;
|
|
}
|
|
const std::string title = modal_mode_ == ModalMode::Busy
|
|
? operation_state_.title()
|
|
: modal_title_;
|
|
return window(text(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;
|
|
|
|
auto base = window(
|
|
text("Actions"),
|
|
vbox(Elements{
|
|
menu_component_->Render() | frame | vscroll_indicator,
|
|
separator(),
|
|
text("Up/Down: navigate Enter: select") | dim,
|
|
})) |
|
|
size(WIDTH, GREATER_THAN, 56) |
|
|
size(WIDTH, LESS_THAN, 90) | center;
|
|
|
|
return modal_mode_ == ModalMode::None
|
|
? base
|
|
: dbox({base, RenderModal() | clear_under | center});
|
|
}
|
|
int TuxDockApp::Run() {
|
|
std::string connection_error;
|
|
if (!docker_.checkConnection(connection_error)) {
|
|
std::cerr << "Unable to connect to Docker Engine.\n"
|
|
<< "Socket: /var/run/docker.sock\n"
|
|
<< "Reason: " << connection_error << "\n\n"
|
|
<< "Ensure Docker is running and your user can access the Docker socket.\n";
|
|
return 1;
|
|
}
|
|
|
|
const auto initial_containers = docker_.getContainerList();
|
|
const auto initial_images = docker_.getImageList();
|
|
if (initial_containers.ok()) containers_ = initial_containers.items;
|
|
if (initial_images.ok()) images_ = initial_images.items;
|
|
if (!initial_containers.ok() || !initial_images.ok()) {
|
|
SetStatus("Docker connected, but initial state could not be fully loaded.");
|
|
} else if (containers_.empty() && images_.empty()) {
|
|
SetStatus("Docker connected. No containers or images found.");
|
|
} else {
|
|
SetStatus("Docker connected. State loaded.");
|
|
}
|
|
|
|
auto root = ftxui::Renderer(menu_component_, [this] { return Render(); });
|
|
auto app = ftxui::CatchEvent(root, [this](ftxui::Event event) {
|
|
return OnEvent(event);
|
|
});
|
|
auto screen = ftxui::ScreenInteractive::TerminalOutput();
|
|
screen_ = &screen;
|
|
state_poller_.start(
|
|
docker_,
|
|
[&screen](std::function<void()> fn) {
|
|
if (ftxui::ScreenInteractive::Active() == &screen) {
|
|
screen.Post(std::move(fn));
|
|
}
|
|
},
|
|
[this](DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
|
DockerManager::ListResult<DockerManager::ImageInfo> images) {
|
|
ApplyRefreshResults(std::move(containers), std::move(images), /*silent=*/true);
|
|
});
|
|
screen.Loop(app);
|
|
state_poller_.stop();
|
|
screen_ = nullptr;
|
|
if (refresh_thread_.joinable()) refresh_thread_.join();
|
|
if (spinner_thread_.joinable()) spinner_thread_.join();
|
|
for (auto& thread : action_threads_) {
|
|
if (thread.joinable()) thread.join();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int main() {
|
|
TuxDockApp app;
|
|
return app.Run();
|
|
}
|