Skip to content

Container Management

Eric Kochen edited this page May 16, 2026 · 5 revisions

Press Tab twice from the Host List (cycle Hosts -> Tunnels -> Containers) or Shift+Tab twice (cycle Hosts -> Keys -> Containers) to land on the Containers tab. Every Docker or Podman container across every cached host is grouped per host. Shell in, stream logs, restart, stop, exec or kick a whole compose stack from one screen. No agent on the remote, no extra ports, just SSH.

The Containers tab

Each host renders as a fold-able group. The divider row carries the host alias only. Inside the group, one row per container shows name, image, state, status text and ports. The detail panel on the right summarises the host (engine version, runtime, sync age, running and exited counts) when the cursor sits on a divider, and the container (image digest, restart policy, mounts, network, healthcheck and recent logs) when it sits on a container row.

── bastion-ams ───────────────────────────────────────────────────────────
  ● nginx-proxy   nginx:1.25-alpine    running  Up 12 days   0.0.0.0:80->80
  ● app-backend   myapp:v2.14.1        running  Up 12 days   127.0.0.1:8080
  ● redis         redis:7-alpine       running  Up 12 days   127.0.0.1:6379
  ...

Press Space on a divider to fold a host away. Folded state persists across sessions in ~/.purple/preferences.

Per-container actions

Key Action
Enter Drop into an interactive shell (docker exec -it … sh -c 'bash || sh'). The TUI suspends, the shell runs in your terminal, the TUI restores when you exit
l Open the logs viewer with the last 200 lines (docker logs --tail 200). j/k to scroll, g/G for top/bottom, / to search (smart case), Esc to return. Search is modeless: while open, type to refine the query, Tab/Shift+Tab step through matches, //Home/End/Backspace/Delete edit the query, Esc exits search
K Restart this container. Confirms first. SIGTERM, 10s grace, then SIGKILL
Ctrl-K Restart every running member of this container's compose stack, one at a time. Exited members are not touched. Confirms first
S Stop this container. Confirms first
e Run a one-off command inside the container. Type the command, press Enter to execute. Output streams in your terminal, then returns to the TUI

Stop, restart and stack-restart need a y/N confirmation. The confirm dialog lists exactly which containers will be cycled so you can spot a misplaced cursor before pressing y.

Per-host actions

When the cursor sits on a host divider:

Key Action
K Restart every running container on this host, one at a time, in listing order. Confirms first
S Stop every running container on this host, one at a time. Confirms first
r Refresh this host's listing

Single-container actions (Enter, l, e) on a divider show a hint pointing you to a container row.

Refresh and adding hosts

Key Action
r Refresh the host under the cursor
R Refresh every cached host in parallel (max four concurrent SSH calls)
a Open a host picker. Pick a host that has no cache entry yet to fetch it for the first time

The cache is read on launch from ~/.purple/container_cache.jsonl and refreshed on demand. List entries older than 30 seconds re-fetch on focus. Fresh hosts that arrive through cloud sync queue an automatic first fetch so the tab fills out without manual a presses.

Detail panel

Press v to toggle. The detail panel reads docker inspect <id> once per container and remembers the result, so navigating up and down through a host's containers does not re-issue SSH calls. Cards include:

  • Identity: image digest, image labels (version, revision, source), compose project and service, hostname, working dir
  • Status: state, uptime since last start, exit code if exited, OOM kill marker, restart count, healthcheck verdict and probe
  • Resource limits: memory cap, CPU cap, PIDs cap
  • Security: user, privileged flag, read-only rootfs, AppArmor and seccomp profiles, capabilities added or dropped
  • Networking: network mode, attached networks with their IPs, log driver
  • Storage: mount count and per-mount source, target and read-only flag
  • Recent logs: last few log lines (the last 50 by default), distinct from the dedicated logs viewer

Compose-stack awareness

Container labels are parsed for com.docker.compose.project and com.docker.compose.service. When present:

  • The detail panel shows the project and service.
  • Ctrl-K on any member restarts every running member of the same project.
  • Stack members from the same host are listed in the confirm dialog so the action is fully visible before you press y.

Engine version

Purple appends a ##purple:engine## sentinel to the listing command so a single round-trip captures both the container list and the daemon version (docker version --format '{{.Server.Version}}'). The version surfaces on the host divider and via the MCP list_containers tool. When the version sub-call fails (older runtime, restricted permissions) the field stays empty and the tab still works.

Per-host C overlay (legacy)

Press C on the Host List to open the original single-host overlay, scoped to the selected host. Both entries share one engine and the same SSH path, so caches stay coherent when switching between them. For multi-host workflows (refresh all, restart whole stacks, jump between hosts) prefer the Containers tab.

Security and validation

  • Container IDs are validated against an ASCII allowlist (alphanumeric, hyphen, underscore, dot) before being passed to shell commands. Crafted names like ; rm -rf / cannot escape the SSH command.
  • Exec prompts reject control characters and embedded newlines.
  • Listing and inspect commands run with BatchMode=yes so a missing askpass cannot block the TUI.
  • Every container action is logged with a fault-domain prefix ([external] for remote errors, [purple] for internal) so support questions land with full context.

How it works

A single SSH call detects the runtime and lists containers. The detection command uses sentinel markers (##purple:docker##, ##purple:podman##, ##purple:none##) to identify which runtime is available, then appends ##purple:engine## to capture the daemon version:

if command -v docker >/dev/null 2>&1; then
  echo '##purple:docker##' && docker ps -a --format '{{json .}}' && \
  echo '##purple:engine##' && \
  { docker version --format '{{.Server.Version}}' 2>/dev/null || true; }
elif command -v podman >/dev/null 2>&1; then
  echo '##purple:podman##' && podman ps -a --format '{{json .}}' && \
  echo '##purple:engine##' && \
  { podman version --format '{{.Server.Version}}' 2>/dev/null || true; }
else echo '##purple:none##'; fi

Container output is parsed as NDJSON. MOTD banners and blank lines are silently skipped. Subsequent refreshes reuse the cached runtime and call docker ps -a --format '{{json .}}' directly. The version sub-call is suffixed with || true so its failure cannot mask a docker ps error.

Caching

Three caches keep the tab responsive:

  • Listing cache (~/.purple/container_cache.jsonl): one line per host with runtime, engine version, fetch timestamp and the parsed container list. Persisted to disk so the tab renders immediately on launch. Entries are evicted when their host disappears from ~/.ssh/config.
  • Inspect cache (in-memory): keyed on container id with a TTL. Powers the detail panel without re-issuing inspect calls as you arrow through.
  • Logs cache (in-memory): separate from the inspect cache so the dedicated logs viewer (l) and the detail-panel log card stay on independent budgets.

Requirements

The SSH user must be able to run docker or podman (membership of the docker group is enough). No root, no special permissions. Nothing is installed on the remote host.

Works through

  • ProxyJump chains (purple uses your full ssh -F <config> so bastion routing is transparent)
  • Password-protected hosts (askpass integration, same as regular connections)
  • Active tunnels

How purple compares

  • vs. Portainer / Dockge: purple manages containers over plain SSH. No agent. No web UI. No extra ports
  • vs. Lazydocker: Lazydocker manages Docker on the local host. purple manages Docker and Podman on every cached remote host from one TUI tab

Clone this wiki locally