0.1 update, make the stop container code more robust and fix some bugs, update the about screen for version 0.1

This commit is contained in:
mrkmntal 2026-08-14 14:28:03 -04:00
commit e8e93d7b21
17 changed files with 449 additions and 43 deletions

View file

@ -35,6 +35,8 @@ add_executable(tux-dock
src/docker_engine_client.cpp
src/http_response_parser.cpp
src/container_parser.cpp
src/operation_state.cpp
src/stop_waiter.cpp
src/docker_manager.cpp
)
@ -66,4 +68,28 @@ if(BUILD_TESTING)
target_link_libraries(tux-dock-container-tests PRIVATE nlohmann_json::nlohmann_json)
target_compile_options(tux-dock-container-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tux-dock-container-tests COMMAND tux-dock-container-tests)
add_executable(tux-dock-operation-tests
tests/test_operation_state.cpp
src/operation_state.cpp
)
target_include_directories(tux-dock-operation-tests PRIVATE src)
target_compile_options(tux-dock-operation-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tux-dock-operation-tests COMMAND tux-dock-operation-tests)
add_executable(tux-dock-stop-tests
tests/test_stop_waiter.cpp
src/stop_waiter.cpp
)
target_include_directories(tux-dock-stop-tests PRIVATE src)
target_compile_options(tux-dock-stop-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tux-dock-stop-tests COMMAND tux-dock-stop-tests)
add_executable(tux-dock-stop-sequence-tests
tests/test_stop_sequence.cpp
src/stop_waiter.cpp
)
target_include_directories(tux-dock-stop-sequence-tests PRIVATE src)
target_compile_options(tux-dock-stop-sequence-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tux-dock-stop-sequence-tests COMMAND tux-dock-stop-sequence-tests)
endif()

42
DEVLOG.md Normal file
View file

@ -0,0 +1,42 @@
# Tux-Dock Development Log
## 0.1-beta
This release establishes the first beta-quality Docker integration and TUI workflow.
### Docker integration
- Added direct Docker Engine API access through `/var/run/docker.sock`.
- Added startup Docker preflight using `GET /_ping` before launching the TUI.
- Added synchronous initial container and image loading.
- Added asynchronous cache refreshes after mutations and interactive sessions.
- Preserved cached state when a refresh fails.
- Added structured container/image JSON mapping with exited-container support.
### Process execution
- Replaced `system()` and `popen()` with a `fork`/`exec` process runner.
- Added captured stdout/stderr and inherited terminal I/O modes.
- Kept direct CLI execution for interactive terminal handoffs.
### Reliability
- Added HTTP response parsing for Content-Length, chunked transfer, and bodyless responses.
- Added robust handling for Docker `204` and idempotent `304` responses.
- Added tri-state stop polling: stopped, running, and unknown.
- Added bounded stop retries after transport timeouts.
- Stop completion now refreshes cached state before showing the result modal.
### TUI
- Replaced the persistent status panel with a centered actions panel.
- Added busy-operation modals with spinner feedback.
- Blocked input during long-running Docker operations.
- Added structured, scrollable container and image list dialogs.
### Tests
- Added HTTP response parser tests.
- Added container JSON mapping tests.
- Added operation-state tests.
- Added stop polling and timeout-sequence regression tests.

View file

@ -2,7 +2,7 @@
### A lightweight C++ Docker TUI
Tux-Dock is a modern **C++17** Docker terminal frontend built with **FTXUI**.
It gives you a guided, keyboard-first TUI for common Docker operations like pulling images, running containers, inspecting IPs, and managing images/containers without memorizing long CLI flags.
It gives you a guided, keyboard-first TUI for common Docker operations without memorizing long CLI flags.
---
@ -10,12 +10,16 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull
- Interactive Docker workflows through a single-screen TUI with modal steps.
- Picker-based selection (arrow keys + Enter) for containers/images instead of numeric menus.
- High-level status panel with responsive wait states for slower operations.
- Busy-operation modals with a spinner and input blocking while Docker work completes.
- Rich container display with state and forwarded ports.
- Interactive shell handoff with clean terminal clear before/after shell transitions.
- Image operations: pull/list/delete with curated quick picks and custom image support.
- Script-to-image workflow: generate Dockerfile from bash script, then optionally build.
- MySQL quick start flow with version/password/port prompts.
- Docker Engine API access through `/var/run/docker.sock` for structured list and lifecycle operations.
- Direct `fork`/`exec` process execution for CLI-backed streaming and interactive commands.
- Persistent container listings that retain exited containers.
- Robust stop handling with state polling, timeout retry, and idempotent stop responses.
- About screen in-app with project/version/repository info.
---
@ -35,12 +39,14 @@ It gives you a guided, keyboard-first TUI for common Docker operations like pull
git clone https://mentalnet.xyz/forgejo/markmental/tuxdock.git
cd tuxdock
# Configure & build (FTXUI is fetched automatically)
cmake -S . -B build
cmake --build build -j
# Configure, build, and test (FTXUI and nlohmann/json are fetched automatically)
./compile.sh
# Run it (requires Docker permissions)
sudo ./build/tux-dock
# Build without running tests
./compile.sh --no-test
```
Prefer a prebuilt binary? CI artifacts are published at:
@ -72,7 +78,7 @@ Current TUI actions:
## Design Overview
Tux-Dock is now structured around **two main classes**:
Tux-Dock is organized around the TUI, Docker manager, Engine API client, and direct process runner:
```cpp
class DockerManager {
@ -85,9 +91,9 @@ public:
bool running;
};
// Docker command/data layer
std::vector<ContainerInfo> getContainerList() const;
std::vector<std::pair<std::string, std::string>> getImageList() const;
bool checkConnection(...);
ListResult<ContainerInfo> getContainerList() const;
ListResult<ImageInfo> getImageList() const;
bool pullImage(...);
bool runContainerInteractive(...);
bool startInteractive(...);
@ -106,11 +112,11 @@ public:
void Run();
private:
// TUI orchestration layer
// TUI orchestration layer with modal busy states
void OpenInput(...);
void OpenSelect(...);
void OpenConfirm(...);
void RunDeferredStatusAction(...);
void BeginBusyOperation(...);
void RunWithRestoredIO(...);
void ExecuteSelectedAction();
// action handlers bridge UI -> DockerManager
@ -119,24 +125,44 @@ private:
### Responsibilities
- `DockerEngineClient`
- Talks directly to Docker over the Unix socket.
- Parses HTTP responses, including chunked and bodyless responses.
- `ProcessRunner`
- Executes direct argument vectors using `fork` and `exec`.
- Supports captured output and inherited terminal I/O.
- `DockerManager`
- Executes Docker commands.
- Escapes shell arguments and returns success/failure + user-facing messages.
- Collects container/image data used by the UI.
- Maps Engine API JSON into application data.
- Performs lifecycle operations and robust stop-state confirmation.
- Preserves cached state when refreshes fail.
- `TuxDockApp`
- Renders the FTXUI interface.
- Manages modal flows (input/select/confirm/message).
- Handles status updates, deferred actions, and interactive shell transitions.
- Handles modal flows, busy operations, blocked input, and interactive shell transitions.
- Coordinates end-to-end user flows by calling `DockerManager` methods.
This split keeps Docker behavior isolated while making UI behavior easier to extend.
---
## Testing
```bash
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build -j
ctest --test-dir build --output-on-failure
```
`compile.sh` runs these tests by default. Pass `--no-test` to skip them.
---
## About / Version
- Version: `022526-dev`
- Version: `0.1-beta`
- Created by: `markmental`
- GitHub: https://github.com/MARKMENTAL/tuxdock
- Forgejo: https://mentalnet.xyz/forgejo/markmental/tuxdock

View file

@ -1,3 +1,27 @@
#!/bin/sh
cmake -S . -B build && cmake --build build -j && echo "tux-dock successfully compiled!"
set -eu
if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != "--no-test" ]; }; then
printf '%s\n' "Usage: $0 [--no-test]" >&2
exit 2
fi
run_tests=1
if [ "$#" -eq 1 ]; then
run_tests=0
fi
if [ "$run_tests" -eq 1 ]; then
cmake -S . -B build -DBUILD_TESTING=ON
else
cmake -S . -B build -DBUILD_TESTING=OFF
fi
cmake --build build -j
if [ "$run_tests" -eq 1 ]; then
ctest --test-dir build --output-on-failure
fi
printf '%s\n' "tux-dock successfully compiled!"

View file

@ -1,4 +1,5 @@
#include "src/docker_manager.hpp"
#include "src/operation_state.hpp"
#include <cctype>
#include <filesystem>
@ -21,7 +22,7 @@ public:
int Run();
private:
enum class ModalMode { None, Input, Confirm, Select, Message };
enum class ModalMode { None, Input, Confirm, Select, Message, Busy };
struct RunContainerContext { std::string image; int port_count = 0; std::vector<std::string> 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; };
@ -46,6 +47,8 @@ private:
ftxui::ScreenInteractive* screen_ = nullptr;
std::thread refresh_thread_;
std::vector<std::thread> action_threads_;
OperationState operation_state_;
std::size_t spinner_frame_ = 0;
static bool IsDigits(const std::string& value);
static std::string ShortId(const std::string& id);
@ -68,6 +71,8 @@ private:
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()>);
void BeginStopOperation(const std::string& id);
void RefreshState(const std::string& message = "Refreshing Docker state...");
void ApplyRefreshResults(DockerManager::ListResult<DockerManager::ContainerInfo> containers,
DockerManager::ListResult<DockerManager::ImageInfo> images);
@ -155,16 +160,49 @@ void TuxDockApp::PromptContainerSelection(const std::string& title, std::functio
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) {
SetStatus(wait);
BeginBusyOperation("Working", wait, std::move(action));
}
void TuxDockApp::BeginBusyOperation(const std::string& title,
const std::string& message,
std::function<std::string()> action) {
operation_state_.begin(title, message);
modal_mode_ = ModalMode::Busy;
spinner_frame_ = 0;
auto* active = screen_;
if (active) active->PostEvent(ftxui::Event::Custom);
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->Post([this, message] {
operation_state_.complete(message);
modal_mode_ = ModalMode::None;
OpenMessage("Operation complete", message);
RefreshState();
});
active->PostEvent(ftxui::Event::Custom);
}
});
}
void TuxDockApp::BeginStopOperation(const std::string& id) {
operation_state_.begin("Stopping container", "Stopping and refreshing state...");
modal_mode_ = ModalMode::Busy;
auto* active = screen_;
action_threads_.emplace_back([this, active, id] {
std::string message;
const bool stopped = docker_.stopContainer(id, message);
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 {
ApplyRefreshResults(std::move(containers), std::move(images));
operation_state_.complete(message);
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_) screen_->WithRestoredIO([&] { if (before) ClearTerminal(); action(); if (after) ClearTerminal(); })(); else action(); }
@ -183,18 +221,18 @@ void TuxDockApp::ActionListImages() {
}
OpenListMessage("Images", FormatImageList(images_));
}
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::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 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<RunContainerContext>(); context->image = tag; PromptPortCountAndRun(context); }); }
void TuxDockApp::PromptPortCountAndRun(const std::shared_ptr<RunContainerContext>& 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<RunContainerContext>& 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::ActionStartDetached() { PromptContainerSelection("Start Detached", [this](const std::string& id, const std::string&) { BeginBusyOperation("Starting container", "Please wait...", [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) BeginBusyOperation("Deleting image", "Please wait...", [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&) { BeginStopOperation(id); }); }
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) BeginBusyOperation("Removing container", "Please wait...", [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::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; BeginBusyOperation("Running command", "Please wait...", [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; BeginBusyOperation("Launching MySQL", "Please wait...", [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;
@ -204,7 +242,7 @@ void TuxDockApp::ActionCreateDockerfile() {
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] {
BeginBusyOperation("Building image", "Creating Dockerfile and running build...", [this, base, script, output, image] {
std::string m;
docker_.createDockerfile(base, script, output, image, m);
return m;
@ -214,11 +252,19 @@ void TuxDockApp::ActionCreateDockerfile() {
});
});
}
void TuxDockApp::ActionAbout() { SetStatus("Tux-Dock 022526-dev\nCreated by markmental"); }
void TuxDockApp::ActionAbout() { OpenMessage("About Tux-Dock", "Tux-Dock 0.1-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: 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; }
bool TuxDockApp::OnEvent(ftxui::Event event) {
if (modal_mode_ == ModalMode::Busy) return true;
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;
@ -232,17 +278,26 @@ ftxui::Element TuxDockApp::RenderModal() const {
} 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::Busy) {
static const std::string spinner = "|/-\\";
body = vbox(Elements{
text(operation_state_.message()),
separator(),
text(std::string(" ") + spinner[spinner_frame_ % spinner.size()]) | bold,
});
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;
}
return window(text(modal_title_), vbox(Elements{body, separator(), footer})) |
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; 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}); }
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)) {

View file

@ -60,14 +60,14 @@ EngineResponse DockerEngineClient::request(const std::string& method,
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"
std::ostringstream wire_request;
wire_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();
const std::string wire = wire_request.str();
std::size_t sent = 0;
while (sent < wire.size()) {
const ssize_t count = write(fd, wire.data() + sent, wire.size() - sent);
@ -82,18 +82,26 @@ EngineResponse DockerEngineClient::request(const std::string& method,
std::string raw;
char buffer[8192];
ssize_t count = 0;
while ((count = read(fd, buffer, sizeof(buffer))) > 0) {
while (!httpResponseComplete(raw, method) && (count = read(fd, buffer, sizeof(buffer))) > 0) {
raw.append(buffer, static_cast<std::size_t>(count));
}
close(fd);
if (count < 0) {
response.error = std::strerror(errno);
return response;
if (errno == EINTR) return request(method, path, body);
if (errno != EAGAIN && errno != EWOULDBLOCK) {
response.error = std::strerror(errno);
return response;
}
if (!httpResponseComplete(raw, method)) {
response.error = "Docker Engine response timed out.";
return response;
}
}
const auto parsed = parseHttpResponse(raw);
response.status_code = parsed.status_code;
response.body = parsed.body;
response.error = parsed.error;
if (response.status_code == 304 && response.error.empty()) response.error.clear();
return response;
}

View file

@ -2,6 +2,7 @@
#include "process_runner.hpp"
#include "container_parser.hpp"
#include "stop_waiter.hpp"
#include <filesystem>
#include <fstream>
@ -133,9 +134,43 @@ bool DockerManager::deleteImage(const std::string& id, std::string& message) con
}
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.");
const auto stop_request = [this, &id] { return engine_.request("POST", "/containers/" + id + "/stop"); };
const auto acceptable = [](const EngineResponse& response) {
return response.status_code == 204 || response.status_code == 304 || response.status_code == 404 || response.ok();
};
const auto probe = [this, &id] {
const EngineResponse state = engine_.request("GET", "/containers/" + id + "/json");
if (!state.ok()) return StopProbe::Unknown;
try {
const bool running = nlohmann::json::parse(state.body)
.value("State", nlohmann::json::object())
.value("Running", true);
return running ? StopProbe::Running : StopProbe::Stopped;
} catch (const nlohmann::json::exception&) {
return StopProbe::Unknown;
}
};
const EngineResponse first = stop_request();
const bool first_timed_out = first.error == "Docker Engine response timed out.";
if (!acceptable(first) && !first_timed_out) {
message = apiError(first, "Could not stop that container.");
return false;
}
StopProbe state = first_timed_out ? StopProbe::Unknown : waitForStopped(probe);
if (state != StopProbe::Stopped) {
const EngineResponse retry = stop_request();
const bool retry_timed_out = retry.error == "Docker Engine response timed out.";
if (!acceptable(retry) && !retry_timed_out) {
message = apiError(retry, "Could not confirm that container stopped.");
return false;
}
state = waitForStopped(probe);
}
if (state != StopProbe::Stopped) {
message = "Stop requested, but container state could not be confirmed.";
return false;
}
message = "Container stopped.";

View file

@ -70,6 +70,9 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) {
return response;
}
const bool bodyless = (response.status_code >= 100 && response.status_code < 200) ||
response.status_code == 204 || response.status_code == 304;
std::size_t content_length = std::string::npos;
bool chunked = false;
std::size_t line_start = status_end + 2;
@ -90,7 +93,9 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) {
}
const std::string payload = raw.substr(header_end + 4);
if (chunked) {
if (bodyless) {
response.body.clear();
} else if (chunked) {
if (!decodeChunked(payload, response.body, response.error)) return response;
} else if (content_length != std::string::npos) {
if (payload.size() < content_length) {
@ -102,7 +107,36 @@ ParsedHttpResponse parseHttpResponse(const std::string& raw) {
response.body = payload;
}
if (response.status_code < 200 || response.status_code >= 300) {
if (response.status_code == 304) return response;
response.error = response.body.empty() ? "Docker Engine request failed." : response.body;
}
return response;
}
bool httpResponseComplete(const std::string& raw, const std::string& method) {
const auto header_end = raw.find("\r\n\r\n");
if (header_end == std::string::npos) return false;
const auto status_end = raw.find("\r\n");
if (status_end == std::string::npos || status_end > header_end) return false;
std::istringstream status_line(raw.substr(0, status_end));
std::string version;
int status_code = 0;
status_line >> version >> status_code;
if (method == "HEAD" || (status_code >= 100 && status_code < 200) || status_code == 204 || status_code == 304) return true;
const std::string headers = raw.substr(status_end + 2, header_end - status_end - 2);
const auto transfer = headers.find("Transfer-Encoding:");
if (transfer != std::string::npos && lower(headers.substr(transfer)).find("chunked") != std::string::npos) {
return raw.size() >= header_end + 7 && raw.find("\r\n0\r\n", header_end + 4) != std::string::npos;
}
const auto length = lower(headers).find("content-length:");
if (length == std::string::npos) return false;
const auto line_end = headers.find("\r\n", length);
const auto value_start = headers.find(':', length);
if (value_start == std::string::npos) return false;
try {
const auto expected = std::stoull(trim(headers.substr(value_start + 1, line_end - value_start - 1)));
return raw.size() >= header_end + 4 + expected;
} catch (...) {
return false;
}
}

View file

@ -7,7 +7,8 @@ struct ParsedHttpResponse {
std::string body;
std::string error;
bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); }
bool ok() const { return ((status_code >= 200 && status_code < 300) || status_code == 304) && error.empty(); }
};
ParsedHttpResponse parseHttpResponse(const std::string& raw);
bool httpResponseComplete(const std::string& raw, const std::string& method = {});

15
src/operation_state.cpp Normal file
View file

@ -0,0 +1,15 @@
#include "operation_state.hpp"
#include <utility>
void OperationState::begin(std::string title, std::string message) {
busy_ = true;
title_ = std::move(title);
message_ = std::move(message);
result_.clear();
}
void OperationState::complete(std::string result) {
busy_ = false;
result_ = std::move(result);
}

20
src/operation_state.hpp Normal file
View file

@ -0,0 +1,20 @@
#pragma once
#include <string>
class OperationState {
public:
void begin(std::string title, std::string message);
void complete(std::string result);
bool busy() const { return busy_; }
const std::string& title() const { return title_; }
const std::string& message() const { return message_; }
const std::string& result() const { return result_; }
private:
bool busy_ = false;
std::string title_;
std::string message_;
std::string result_;
};

14
src/stop_waiter.cpp Normal file
View file

@ -0,0 +1,14 @@
#include "stop_waiter.hpp"
#include <thread>
StopProbe waitForStopped(const std::function<StopProbe()>& probe,
std::chrono::milliseconds timeout,
std::chrono::milliseconds interval) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (probe() == StopProbe::Stopped) return StopProbe::Stopped;
std::this_thread::sleep_for(interval);
}
return probe();
}

10
src/stop_waiter.hpp Normal file
View file

@ -0,0 +1,10 @@
#pragma once
#include <chrono>
#include <functional>
enum class StopProbe { Stopped, Running, Unknown };
StopProbe waitForStopped(const std::function<StopProbe()>& probe,
std::chrono::milliseconds timeout = std::chrono::milliseconds(2000),
std::chrono::milliseconds interval = std::chrono::milliseconds(100));

View file

@ -34,10 +34,32 @@ void testErrorResponse() {
assert(response.error == "bad");
}
void testCompleteResponseDoesNotNeedPeerClose() {
const std::string raw = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n";
assert(httpResponseComplete(raw));
const auto response = parseHttpResponse(raw);
assert(response.ok());
assert(response.status_code == 204);
}
void testBodylessStatuses() {
for (const int status : {204, 304}) {
const std::string raw = "HTTP/1.1 " + std::to_string(status) + " Status\r\n\r\n";
assert(httpResponseComplete(raw));
const auto parsed = parseHttpResponse(raw);
if (!parsed.ok()) std::cerr << "status=" << status << " error=" << parsed.error << "\n";
assert(parsed.ok());
}
const std::string head = "HTTP/1.1 200 OK\r\n\r\n";
assert(httpResponseComplete(head, "HEAD"));
}
int main() {
testContentLength();
testChunked();
testTruncatedBody();
testErrorResponse();
testCompleteResponseDoesNotNeedPeerClose();
testBodylessStatuses();
std::cout << "HTTP response parser tests passed\n";
}

View file

@ -0,0 +1,19 @@
#include "operation_state.hpp"
#include <cassert>
#include <iostream>
int main() {
OperationState operation;
assert(!operation.busy());
operation.begin("Stopping container", "Please wait...");
assert(operation.busy());
assert(operation.title() == "Stopping container");
assert(operation.message() == "Please wait...");
operation.complete("Container stopped.");
assert(!operation.busy());
assert(operation.result() == "Container stopped.");
std::cout << "Operation state tests passed\n";
}

View file

@ -0,0 +1,22 @@
#include "stop_waiter.hpp"
#include <cassert>
#include <iostream>
int main() {
int stop_requests = 0;
int probes = 0;
const auto first = [&] {
++stop_requests;
return stop_requests == 1 ? StopProbe::Unknown : StopProbe::Stopped;
};
const auto state = waitForStopped([&] {
++probes;
return probes < 2 ? StopProbe::Unknown : StopProbe::Stopped;
}, std::chrono::milliseconds(10), std::chrono::milliseconds(1));
assert(state == StopProbe::Stopped);
assert(first() == StopProbe::Unknown);
assert(first() == StopProbe::Stopped);
assert(stop_requests == 2);
std::cout << "Stop sequence tests passed\n";
}

View file

@ -0,0 +1,33 @@
#include "stop_waiter.hpp"
#include <cassert>
#include <chrono>
#include <iostream>
int main() {
int calls = 0;
const auto stopped = waitForStopped([&] {
return ++calls < 3 ? StopProbe::Running : StopProbe::Stopped;
},
std::chrono::milliseconds(100),
std::chrono::milliseconds(1));
assert(stopped == StopProbe::Stopped);
const auto timed_out = waitForStopped([] { return StopProbe::Unknown; },
std::chrono::milliseconds(5),
std::chrono::milliseconds(1));
assert(timed_out == StopProbe::Unknown);
int transient_calls = 0;
const auto transient = waitForStopped([&] {
++transient_calls;
if (transient_calls < 3) return StopProbe::Unknown;
return StopProbe::Stopped;
}, std::chrono::milliseconds(100), std::chrono::milliseconds(1));
assert(transient == StopProbe::Stopped);
// A failed refresh must not be interpreted as a stopped state.
const auto preserved = waitForStopped([] { return StopProbe::Unknown; },
std::chrono::milliseconds(3),
std::chrono::milliseconds(1));
assert(preserved == StopProbe::Unknown);
std::cout << "Stop waiter tests passed\n";
}