v0.4.2 - Implemented the new Run Command in Container (with output) feature

This commit is contained in:
mrkmntal 2026-08-30 21:39:34 -04:00
commit 5ab40b082b
4 changed files with 403 additions and 25 deletions

View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Exit immediately if a command exits with a non-zero status
set -e
# Ensure the script is run as root
if [ "$EUID" -ne 0 ]; then
echo "Error: Please run this script as root or with sudo." >&2
exit 1
fi
echo "=== 1. Updating System Repositories ==="
apt update && apt upgrade -y
echo "=== 2. Installing Nginx and PHP 8.4 ==="
apt install -y nginx php8.4-fpm php8.4-cli php8.4-common
echo "=== 3. Adjusting PHP-FPM Timeouts ==="
PHP_INI="/etc/php/8.4/fpm/php.ini"
if [ -f "$PHP_INI" ]; then
sed -i 's/^max_execution_time =.*/max_execution_time = 60/' "$PHP_INI"
echo "Set max_execution_time = 60 in $PHP_INI"
else
echo "Warning: $PHP_INI not found, skipping inline adjustment."
fi
echo "=== 4. Configuring Nginx Server Block ==="
cat << 'EOF' > /etc/nginx/sites-available/default
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.php index.html index.htm;
server_name _;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
# Prevent Nginx from cutting off the connection prematurely
fastcgi_read_timeout 60s;
}
location ~ /\.ht {
deny all;
}
}
EOF
echo "=== 5. Creating the 30-Second Sleep Script ==="
cat << 'EOF' > /var/www/html/sleep.php
<?php
ob_end_clean();
header('Content-Type: text/plain');
header('Cache-Control: no-cache');
echo "Test started at: " . date('H:i:s') . "\n";
echo "Sleeping for 30 seconds...\n";
flush();
sleep(30);
echo "Test finished at: " . date('H:i:s') . "\n";
?>
EOF
# Fixed typo: Added missing comment symbol here
chown -R www-data:www-data /var/www/html/sleep.php
echo "=== 6. Validating Configuration ==="
nginx -t
echo "=== 7. Starting Services via /etc/init.d/ ==="
# Stop any existing processes safely before refreshing
/etc/init.d/nginx stop || true
/etc/init.d/php8.4-fpm stop || true
# Start PHP-FPM first to ensure the Unix domain socket is generated
/etc/init.d/php8.4-fpm start
/etc/init.d/nginx start
echo "====================================================="
echo " Installation Complete!"
echo " Test the setup via CLI using:"
echo " time curl -i http://localhost/sleep.php"
echo "====================================================="

298
main.cpp
View file

@ -2,13 +2,15 @@
#include "src/docker_state_poller.hpp" #include "src/docker_state_poller.hpp"
#include "src/operation_state.hpp" #include "src/operation_state.hpp"
#include <algorithm>
#include <cctype> #include <cctype>
#include <csignal>
#include <atomic>
#include <chrono> #include <chrono>
#include <csignal>
#include <ctime>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <functional> #include <functional>
#include <iomanip>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <sstream> #include <sstream>
@ -20,6 +22,7 @@
#include <ftxui/component/component.hpp> #include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp> #include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp> #include <ftxui/dom/elements.hpp>
#include <nlohmann/json.hpp>
namespace { namespace {
@ -101,14 +104,132 @@ void RemoveSignalTraps() {
} }
} }
} // 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 { class TuxDockApp {
public: public:
int Run(); int Run();
private: private:
enum class ModalMode { None, Input, Confirm, Select, Message, Busy }; enum class ModalMode { None, Input, Confirm, Select, Message, Busy, History };
struct RunContainerContext { struct RunContainerContext {
std::string name; std::string name;
std::string image; std::string image;
@ -130,6 +251,7 @@ private:
"Remove Container", "Remove Container",
"Attach Shell to Running Container", "Attach Shell to Running Container",
"Run Detached Command in Container", "Run Detached Command in Container",
"Run Command in Container (with output)",
"About Tux-Dock", "About Tux-Dock",
"Exit", "Exit",
}; };
@ -145,6 +267,12 @@ private:
ftxui::Component menu_component_ = ftxui::Menu(&menu_entries_, &menu_selected_); ftxui::Component menu_component_ = ftxui::Menu(&menu_entries_, &menu_selected_);
ftxui::Component input_component_ = ftxui::Input(&modal_input_, "Type here"); ftxui::Component input_component_ = ftxui::Input(&modal_input_, "Type here");
ftxui::Component select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); 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, const std::string&)> input_callback_;
std::function<void(bool)> confirm_callback_; std::function<void(bool)> confirm_callback_;
std::function<void(bool, int)> select_callback_; std::function<void(bool, int)> select_callback_;
@ -166,15 +294,18 @@ private:
ftxui::Element FormatImageList(const std::vector<DockerManager::ImageInfo>& images) const; ftxui::Element FormatImageList(const std::vector<DockerManager::ImageInfo>& images) const;
void SetStatus(const std::string& message) { status_ = message; } void SetStatus(const std::string& message) { status_ = message; }
void OpenInput(const std::string&, const std::string&, void OpenInput(const std::string&, const std::string&,
std::function<void(bool, const std::string&)>, bool secret = false); 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 OpenConfirm(const std::string&, const std::string&, std::function<void(bool)>);
void OpenSelect(const std::string&, const std::string&, std::vector<std::string>, void OpenSelect(const std::string&, const std::string&, std::vector<std::string>,
std::function<void(bool, int)>); std::function<void(bool, int)>);
void OpenMessage(const std::string& title, const std::string& text); void OpenMessage(const std::string& title, const std::string& text);
void OpenListMessage(const std::string& title, ftxui::Element content); void OpenListMessage(const std::string& title, ftxui::Element content);
void OpenHistory();
void ResolveInput(bool); void ResolveInput(bool);
void ResolveConfirm(bool); void ResolveConfirm(bool);
void ResolveSelect(bool); void ResolveSelect(bool);
void ResolveHistory();
void CloseMessage(); void CloseMessage();
void ExecuteSelectedAction(); void ExecuteSelectedAction();
void ActionPullImage(); void ActionPullImage();
@ -187,6 +318,9 @@ private:
void ActionRemoveContainer(); void ActionRemoveContainer();
void ActionExecShell(); void ActionExecShell();
void ActionExecDetachedCommand(); void ActionExecDetachedCommand();
void OpenDetachedCommandInput(const std::string& initial_value = "");
void ActionExecCommandWithOutput();
void OpenCommandWithOutputInput(const std::string& initial_value = "");
void ActionAbout(); void ActionAbout();
void PromptPortCountAndRun(const std::shared_ptr<RunContainerContext>&); void PromptPortCountAndRun(const std::shared_ptr<RunContainerContext>&);
void PromptNextPort(const std::shared_ptr<RunContainerContext>&, int); void PromptNextPort(const std::shared_ptr<RunContainerContext>&, int);
@ -197,7 +331,8 @@ private:
const std::string&, const std::string&,
std::function<void(const std::string&, const std::string&)>); std::function<void(const std::string&, const std::string&)>);
void RunDeferredStatusAction(const std::string&, std::function<std::string()>); void RunDeferredStatusAction(const std::string&, std::function<std::string()>);
void BeginBusyOperation(const std::string&, 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 StartSpinner();
void StopSpinner(); void StopSpinner();
void BeginStopOperation(const std::string& id, int timeout_seconds = 10); void BeginStopOperation(const std::string& id, int timeout_seconds = 10);
@ -307,11 +442,12 @@ void TuxDockApp::ApplyRefreshResults(
void TuxDockApp::OpenInput(const std::string& title, void TuxDockApp::OpenInput(const std::string& title,
const std::string& text, const std::string& text,
std::function<void(bool, const std::string&)> callback, std::function<void(bool, const std::string&)> callback,
bool secret) { bool secret,
const std::string& initial_value) {
modal_mode_ = ModalMode::Input; modal_mode_ = ModalMode::Input;
modal_title_ = title; modal_title_ = title;
modal_text_ = text; modal_text_ = text;
modal_input_.clear(); modal_input_ = initial_value;
ftxui::InputOption option; ftxui::InputOption option;
option.password = secret; option.password = secret;
@ -463,7 +599,8 @@ void TuxDockApp::StopSpinner() {
void TuxDockApp::BeginBusyOperation(const std::string& title, void TuxDockApp::BeginBusyOperation(const std::string& title,
const std::string& message, const std::string& message,
std::function<std::string()> action) { std::function<std::string()> action,
std::function<void(const std::string&)> on_complete) {
operation_state_.begin(title, message); operation_state_.begin(title, message);
modal_mode_ = ModalMode::Busy; modal_mode_ = ModalMode::Busy;
spinner_frame_ = 0; spinner_frame_ = 0;
@ -471,15 +608,19 @@ void TuxDockApp::BeginBusyOperation(const std::string& title,
auto* active = screen_; auto* active = screen_;
StartSpinner(); StartSpinner();
if (active) active->PostEvent(ftxui::Event::Custom); if (active) active->PostEvent(ftxui::Event::Custom);
action_threads_.emplace_back([this, active, action = std::move(action)]() mutable { action_threads_.emplace_back([this, active, action = std::move(action), on_complete = std::move(on_complete)]() mutable {
const auto message = action(); const auto message = action();
if (active && ftxui::ScreenInteractive::Active() == active) { if (active && ftxui::ScreenInteractive::Active() == active) {
active->Post([this, message] { active->Post([this, message, on_complete = std::move(on_complete)] {
RemoveSignalTraps(); RemoveSignalTraps();
operation_state_.complete(message); operation_state_.complete(message);
StopSpinner(); StopSpinner();
modal_mode_ = ModalMode::None; modal_mode_ = ModalMode::None;
OpenMessage("Operation complete", message); if (on_complete) {
on_complete(message);
} else {
OpenMessage("Operation complete", message);
}
state_poller_.trigger_now(); state_poller_.trigger_now();
}); });
active->PostEvent(ftxui::Event::Custom); active->PostEvent(ftxui::Event::Custom);
@ -707,23 +848,105 @@ void TuxDockApp::ActionExecDetachedCommand() {
PromptContainerSelection( PromptContainerSelection(
"Run Detached Command", "Run Detached Command",
[this](const std::string& id, const std::string& name) { [this](const std::string& id, const std::string& name) {
OpenInput( history_container_id_ = id;
"Detached Command", "Enter command to run in " + name + ":", history_container_name_ = name;
[this, id](bool ok, const std::string& command) { OpenDetachedCommandInput();
if (!ok) return;
BeginBusyOperation(
"Running command", "Please wait...",
[this, id, command] {
std::string message;
docker_.execDetachedCommand(id, command, message);
return message;
});
});
}); });
} }
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() { void TuxDockApp::ActionAbout() {
OpenMessage("About Tux-Dock", "Tux-Dock 0.4.1-beta | Created by markmental"); OpenMessage("About Tux-Dock", "Tux-Dock 0.4.2-beta | Created by markmental");
} }
void TuxDockApp::ExecuteSelectedAction() { void TuxDockApp::ExecuteSelectedAction() {
@ -738,8 +961,9 @@ void TuxDockApp::ExecuteSelectedAction() {
case 7: ActionRemoveContainer(); break; case 7: ActionRemoveContainer(); break;
case 8: ActionExecShell(); break; case 8: ActionExecShell(); break;
case 9: ActionExecDetachedCommand(); break; case 9: ActionExecDetachedCommand(); break;
case 10: ActionAbout(); break; case 10: ActionExecCommandWithOutput(); break;
case 11: case 11: ActionAbout(); break;
case 12:
if (screen_) screen_->ExitLoopClosure()(); if (screen_) screen_->ExitLoopClosure()();
break; break;
default: break; default: break;
@ -752,6 +976,10 @@ bool TuxDockApp::OnEvent(ftxui::Event event) {
return true; return true;
} }
if (modal_mode_ == ModalMode::Input) { if (modal_mode_ == ModalMode::Input) {
if (event == ftxui::Event::F1) {
OpenHistory();
return true;
}
if (event == ftxui::Event::Return) { if (event == ftxui::Event::Return) {
ResolveInput(true); ResolveInput(true);
return true; return true;
@ -762,6 +990,17 @@ bool TuxDockApp::OnEvent(ftxui::Event event) {
} }
return input_component_->OnEvent(event); 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 (modal_mode_ == ModalMode::Confirm) {
if (event == ftxui::Event::Return || event == ftxui::Event::Character("y")) { if (event == ftxui::Event::Return || event == ftxui::Event::Character("y")) {
ResolveConfirm(true); ResolveConfirm(true);
@ -817,6 +1056,13 @@ ftxui::Element TuxDockApp::RenderModal() const {
select_component_->Render() | frame | vscroll_indicator, select_component_->Render() | frame | vscroll_indicator,
}); });
footer = text("Up/Down: choose Enter: confirm Esc: cancel") | dim; 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) { } else if (modal_mode_ == ModalMode::Busy) {
static const std::string spinner = "|/-\\"; static const std::string spinner = "|/-\\";
Elements busy_elements = Elements{ Elements busy_elements = Elements{

View file

@ -237,3 +237,38 @@ bool DockerManager::execDetachedCommand(const std::string& id,
if (ok) message = "Command dispatched in detached mode."; if (ok) message = "Command dispatched in detached mode.";
return ok; return ok;
} }
bool DockerManager::execCommandWithOutput(const std::string& id,
const std::string& command,
std::string& output,
std::string& message) const {
if (command.empty()) {
message = "Please provide a command to run.";
return false;
}
ProcessOptions options;
options.capture_stdout = true;
options.capture_stderr = true;
options.inherit_stdio = false;
const ProcessResult result = ProcessRunner::run(
{"docker", "exec", id, "/bin/sh", "-c", command}, options);
output = result.stdout_text;
if (!result.stderr_text.empty()) {
if (!output.empty()) output += "\n";
output += result.stderr_text;
}
if (result.signaled) {
message = "Command terminated by signal " + std::to_string(result.signal) + ".";
return false;
}
if (result.exit_code != 0) {
message = "Command exited with code " + std::to_string(result.exit_code) + ".";
return false;
}
message = "Command completed successfully.";
return true;
}

View file

@ -50,6 +50,10 @@ public:
bool execDetachedCommand(const std::string& containerId, bool execDetachedCommand(const std::string& containerId,
const std::string& command, const std::string& command,
std::string& message) const; std::string& message) const;
bool execCommandWithOutput(const std::string& containerId,
const std::string& command,
std::string& output,
std::string& message) const;
private: private:
std::unique_ptr<DockerEngineClient> engine_; std::unique_ptr<DockerEngineClient> engine_;