Skip to content
dsh-market Browse plugins GitHub 中文

hyzyn/dsh-plugin-kit#docker

Docker container panel for the DSH Web GUI: containers (optionally including stopped), docker inspect details, logs, stats snapshots, image list and one-shot exec on the local machine or SSH hosts — read-only by default (mutations and exec are permission-gated), with a per-container terminal drawer embedded via the tty plugin.

Stars ★ 46 Category UI Enhancements Listed 2026-09-13 npm @hyzyn/dsh-docker

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add @hyzyn/dsh-docker

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

中文 | English

The DSH sidebar "Containers" panel: containers on the local machine and on SSH hosts inspected on one screen — read logs, watch resources, enter containers, start / stop / remove, Read-only by default.

Features

  • A resident session right-sidebar tab: the panel lives as a session right-sidebar tab (sidebar.right.pane.tab) side by side with the conversation — after handing an error to the agent the logs stay on the right, without getting in the way of watching it work; collapsing it leaves the viewport. Panel-level state (target / view / filter text / selected container and its tab) is kept across sessions, and collapsing drops the live streams to hand the SSH channels back. Hosts without the right-sidebar services fall back to the original dock / modal, behaving exactly as before.
  • An explicit one-click "connect local": two non-permanent entries — with no targets at all it is the primary button in the "no Docker targets configured yet" empty state, and once you have other targets it is the last item in the target dropdown, + Connect local (it is deliberately not on the toolbar, where being permanent means occupying space and begging for clicks — pure noise) — one click (or picking that item) adds the machine hosting the plugin as a local target and selects it (an existing local target is reused, never duplicated; SSH and custom targets stay untouched), and the same operation is docker_connect_local on the agent side. It does not change the meaning of an empty targets, never invents an implicit default target, never installs Docker / starts a daemon / switches docker contexts, and needs no capability grant (it only adds one local target pointing at the plugin's own host).
  • Aggregated fetches across targets: the Overview page fans out over every targets[] entry in parallel, and an unreachable target only spoils its own cell; the agent side exposes the same shape through docker_ps target:"*" / docker_attention target:"*", so targets never block one another.
  • "Needs attention" reads authoritative fields: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / dead; OOM and the real exit code come from one docker inspect — the 137 in a docker ps summary cannot separate an OOM kill from a manual kill, so filtering on the summary alone must misreport.
  • Four long-lived SSE streams on one substrate: log FOLLOW, docker stats, docker events and docker pull all run through the same openSseStream (heartbeat / active-stream registry / teardown on disconnect) and differ only in how they end — logs and pulls finish on their own, stats and events are aborted by the browser. Multi-select merged logs recover true cross-container ordering from the --timestamps prefix; "pause" freezes rendering only (the stream keeps receiving and flushes in one batch on resume).
  • Read-only by default, capability switches in three tiers: start / stop / remove, exec and image mutations are independent switches; while one is off the agent tools are not registered and the HTTP routes return 403 (the capability does not exist, rather than failing when called). Container names and IDs pass a whitelist, every command is built as argv with single-quote escaping, and passwords / passphrases are referenced as env:NAME (resolved through the official credential layer, falling back to the environment) and never sent back to the browser.
  • Right-click a log into the agent: select the failing lines in the log view, then right-click for "send to the current session / fill the input box so I can edit first" — the selection travels with its target, container, time window and 20 lines of context on each side (the menu states plainly that the content enters the model context and may carry credentials). Delivery reports itself twice: a viewport-level toast (attached to body, above the panel and the terminal modal), and — when the terminal panel is open — an automatic fold of the terminal (minimize() on the ttyPanel v2 contract; sessions keep running and the sidebar "Terminal" entry's badge restores it), so the conversation is simply there. On older tty without that call it degrades to the toast's "the conversation is behind the panel" hint. To edit first, use the draft action — the session input box is the only editing surface (multi-line, with the full context in view, and exactly what the agent receives), instead of a second, weaker card editor. Locating the target session is version-tolerant: ≤0.1.5 reads current off the sessions.list snapshot, while since 0.1.6 that field moved out of the session domain together with view selection, so it reads retainedBy.mainView > 0 instead (the same test dsh-client-ui-session and this repo's codegraph use) — trusting only the legacy field greys out both menu items as "no session open". The same read drives "carry the containers tab across a session switch".
  • Data-level reuse of dsh-tty, no code coupling: no tty code is imported and tty needs no source change, so the two install and upgrade independently; with tty present three optional extension points are consumed — connection-bar actions (ttyConnbar), the terminal host (ttyTerminal: a new tab under the tab/dock carriers, an in-place drawer under the modal) and the terminal-side dock (ttyPanel.mountPane, used only by the fallback path) — and each degrades silently without tty or below the required version.

Relationship with dsh-tty

This plugin stands on its own: it imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently. With tty installed they cooperate at the optional extension points below, and without it they degrade quietly.

Dimension Description
Plugin form A standalone package @hyzyn/dsh-docker that imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently
Connection book SSH targets can reference a tty connection-book entry name (read-only access to sshHosts through ctx.settings.get('tty')); when tty is not installed this degrades to "inline host/username" or a local target
Host fingerprints This plugin keeps its own hostKeys (TOFU) and prefers tty's already-recorded fingerprints as the seed — the same host does not have to be confirmed in two places
Execution channel Its own pooled SSH exec (src/ssh-exec.ts), fully independent of tty's PTY sessions; neither takes the other's slots
Context entry point With tty ≥ 0.13.0 it can optionally consume tty's client service ttyConnbar and insert a "Containers" button in the SSH connection bar (next to SFTP) (shown as soon as it is registered), with the target resolved from the current session at click time; except in exec tabs this plugin opened itself — those tabs are where the user just came from, so offering a way back to the very same panel is a loop (recognised via spawnSpec.command; a docker exec -it the user typed by hand does not count); if tty is missing or too old this is skipped silently
Panel hosting The default is a session right-sidebar tab (sidebar.right.pane.tab): the panel and the conversation share the screen, so logs stay visible while the agent works; collapsing it leaves the viewport without leaving the session. The frame sidebar entry only opens or focuses it, and a page type deduplicates inside one column, so clicking twice never opens a second tab. The terminal connection bar entry is the opposite — it sits on the viewport-covering tty modal, where a tab would be hidden, so that path docks to the right of the terminal via ttyPanel.mountPane (the entry decides the carrier). Without the right-sidebar services (older DSH), or with localStorage['dsh-docker:carrier'] = 'modal', the sidebar entry also falls back to the dock (when tty is open) or to a full-screen modal with its own backdrop. All three carriers are one component, differing only in shell and geometry
Terminal hosting Interactive terminals are hosted by tty (it owns the PTY). Right-sidebar tab: when the panel is fullscreen (sidebar.fullscreen) the terminal is embedded in place via ttyTerminal.mount (enough width, logs and shell on one screen); otherwise a tab is opened in the terminal panel via ttyTerminal.open (re-clicking the same container focuses the existing tab instead of stacking duplicates — tty contract v3 reuse); (too narrow to squeeze both). Dock carrier (the panel already lives inside tty) always opens a tab; modal embeds in place. Those command tabs (non-empty spawnSpec.command) show no connection-bar extension area on the tty side — SFTP / tunnels / third-party panes all act on the connection itself, which misleads on a docker exec tab (SFTP browses the host, not what the user believes is inside the container); this plugin adds a version-independent fallback that withholds the "Containers" entry in exec tabs it opened itself (matched by the spawnSpec.command prefix). Without tty, or below the required version, copying the command is the fallback. This plugin implements no PTY / xterm / reconnect stack
Division of labour Interactive troubleshooting (docker exec -it, a shell inside the container, TUIs) is hosted by tty (embedded drawer or tab); read-only inspection and agent automation use this plugin's own exec channel

Reuse at the data level without coupling at the code level: the connection book and the fingerprint seed are "reading the same settings", and the connection-bar button is "consuming a generic extension point" — neither is "depending on tty's modules", so upgrading or uninstalling tty does not break this plugin along with it.

Installation

dsh plugin --profile web add @hyzyn/dsh-docker              # npm install (once published)
dsh plugin --profile web add link:$(pwd)/packages/docker    # repo development and debugging

The aggregate package @hyzyn/dsh-all (or the repo-root bundle) already includes this plugin, so there is no need to add it separately when installing everything at once. After installing, restart dsh web and the "Containers" entry appears in the sidebar; the Settings → Plugins → "Docker Container Panel" card maintains targets and switches, and saving applies hot (settings/updated triggers re-resolution, with no restart needed).

If you have already installed @hyzyn/dsh-all or the root bundle in the web profile, do not add this package again, or the plugin line is mounted twice and startup reports duplicate loader entry id.

Usage

Two entry points, one panel. The default carrier is a session right-sidebar tab — the entries only open or focus it, and the panel sits side by side with the conversation: after handing an error to the agent the logs stay on the right, without getting in the way of watching it work.

  • Sidebar "Containers" (the main entry point): works for any target, including local docker and switching between multiple targets. If it is already open it focuses (a page type deduplicates inside one column), so clicking twice never opens a second "Docker containers" tab.
  • Where to look after delivering: under the right-sidebar tab the conversation is right beside you; under the docked / modal carriers a successful send folds the terminal automatically (it keeps running — click the sidebar "Terminal" entry's badge to restore), so you land in the conversation instead of guessing whether a viewport-covering modal reacted at all.
  • SSH connection bar "Containers" button (a contextual shortcut, tty ≥ 0.13.0): in an SSH tab of the tty terminal panel a "Containers" button appears next to the connection bar's SFTP button — shown as soon as it is registered, and clicking it opens the panel directly on the host of the current session, with no target to pick. The target is resolved at click time: when the session comes from the connection book it matches by entry name, otherwise it matches a resolved target by host:port; no matching target does not hide the button — the panel carries a hint naming the session host (including the connection-book name) and how to configure it in the settings card. This path goes to the dock right of the terminal rather than the tab — the button lives on the tty modal, which covers the viewport, so a tab would be hidden behind it and feel like "clicking did nothing". The target still travels into the panel and remounts it on that host. The rule is therefore "the entry decides the carrier": from the frame's sidebar → the right-sidebar tab (side by side with the conversation); from inside the terminal modal → the dock right of the terminal (side by side with the terminal).

Carriers

Carrier When Behaviour
Session right-sidebar tab (default) the host provides sidebarRight / sidebarRightTabs side by side with the conversation; collapsing hides it without losing state (panel-level state lives in a module store, see below); the right sidebar's fullscreen mode gives it the whole viewport
Dock right of the terminal arriving from the terminal connection bar's "Containers" button (that button sits on the tty modal, which would hide a tab), or no right-sidebar service / localStorage['dsh-docker:carrier'] = 'modal' with tty ≥ 0.16 and its panel open docked to the right of the terminal panel (resize / collapse / ✕ provided by tty, the terminal stays usable); one dock at a time — docking this plugin takes down the previous occupant (for example tty's own SFTP); since tty 0.18.4 the pane is owned by the tab it was opened from: switching tabs hides it, switching back restores it and closing that tab tears it down (while hidden the React tree and polling keep running)
Full-screen modal (fallback) neither of the above its own backdrop, closes on outside click; the panel sits above tty's modal in z-order

Rolling back to the old shape is one console line: localStorage.setItem('dsh-docker:carrier', 'modal') (removeItem restores the default). That switch is a temporary grey-release knob, so it deliberately stays out of settings — not worth changing the host config schema, the settings card and the docs for it.

State retention: the panel inspects hosts, not workspaces, so view / filter text / selected container (including its overview-logs-stats tab) / target are kept across sessions; the log filter and LINES switch inside a container detail belong to that container and are not kept. Collapsing the tab drops every live stream (handing the SSH channels back) and expanding reconnects — single-container and merged log streams already open with tail, so history refills itself.

Stickiness across sessions: DSH's right-sidebar tab records are session-scoped (sidebar.right.pane.tab and rightbar.session both declare scope: 'session'), so a tab opened in session A does not exist in session B. The panel inspects hosts, though, and losing it on a session switch is pure loss — so the plugin additionally keeps a "the user wants this open" intent: switching sessions reopens the tab in the new session, and only clicking the tab's ✕ stops that. The panel follows the person, not the session. That ✕ is executed by the host (sidebarRight closes the tab itself), so the plugin learns about it through registerCloseHandler — relying on the panel's own onClose alone would miss the close, and every session switch would bring the tab back after the user had just dismissed it.

Dock fallback carrier (right of the terminal):

A panel opened this way never covers the terminal: with tty ≥ 0.16 it docks to the right of the terminal panel (draggable width, collapsible into a narrow strip, ✕ to tuck away) while you keep typing in the terminal. In dock mode the card's "Terminal" button opens a new tab in the same terminal panel running docker exec -it (the panel is already inside a terminal, so nesting one more layer makes no sense); it also stops rendering the panel's own header — the title and ✕ are handled by the sidebar title bar, and the refresh control and read-only badge move to the end of the toolbar, right-aligned (while refreshing the icon spins itself, with no extra spinner): the left end stays for the target / view / search / filter controls, so refresh is not mistaken for the first filter and sits where it does in the non-dock header; a 520px narrow column does not leave a blank line behind.

Inside the panel:

  • Switching targets has a transition and guards: as soon as the picker changes, the cards below are still the previous target's (a remote round trip can take up to 20s). Three principles for the transition layer — no layout shift, a sense of direction, little grey:

    • a 2px indeterminate shimmer progress bar (absolutely positioned) along the top of the panel body gives a global "fetching" signal;
    • a blue capsule floats up centred at the top of the body (the same placement and colour language as the dsh-rss loading capsule): background color-mix(accent 12%, surface), border accent 42%, text and spinner in --dk-accent; the visible copy is just two segments, ⟳ Switching to Target2 · Currently showing: Target1 (not written as a sentence, no quotes around target names), while the full "why won't it respond" story lives in title, available on hover;
    • old data stays at 82% + slight desaturation (not dimmed into illegibility) and the pointer is locked: it reads as "a different batch of content" rather than "it broke", and it also prevents acting on the stale list — that would aim at the new target while sending commands with container IDs from the old list and really could stop a same-named container on the other side;
    • new data lands with an 8px slide-up + fade-in over 200ms, so that "a different batch" is visible; the motion respects prefers-reduced-motion.

    Everything above is absolutely positioned: during a switch the first card's position and the scroll height provably do not change at all (a banner approach would push the whole block of content down). A failed switch clears the old list and settles into the new target's error state (it does not keep showing another target's data), and the empty-state copy distinguishes "failed to read" from "filtered too narrowly".

  • Target selection: the panel picks a target first (from the targets config, local / SSH); with only one target configured it is selected by default, and agent tools may also omit the target parameter. The last selected target is remembered (stored in the browser's localStorage under dsh-docker:last-target, not written to config or settings): the next time the panel opens it is selected automatically. The priority is what the connection bar specifies > last remembered (and still present) > first in the list — once the remembered target is deleted or renamed it falls back to the first one instead of resting on an "unknown target"; when entering from the terminal connection bar's "Containers" button, the current session host takes priority over the remembered value. Switching targets is a whole-context switch: requests still in flight for the previous target are all invalidated (a list write gate), so there is no cross-talk like "the picker is already Target2 while the cards are still Target1's containers", and an old target's timeout banner does not linger on the new target's page.

  • Multi-target Overview (read-only): the Overview pill next to the target picker (shown only with ≥2 targets configured) — pick no target and see every host on one screen: a row of counter cards on top (target name + a local / SSH marker + running / stopped / unhealthy), and below it a table of containers needing attention pinned to the top (container / target / status / image; unhealthy sorts before restarting, ties follow the targets' order in config, and rows do not jump between polls). Fetching is parallel + progressive: each target issues its own POST /containers (all:true, since a bare docker ps does not return exited containers and "stopped" would always be 0), and an unreachable target affects only its own cell — the card outlined in red plus an "N targets unreachable" banner at the top, while the other targets' results show as usual (deliberately not aggregated with Promise.all: an unreachable SSH host waits out the 20s readyTimeout, so aggregating would keep the whole page silent for 20s). Clicking a counter card returns to that target's normal container list, and clicking an attention row opens that container's details (going back lands on that target's container list); the Overview performs no cross-target operations, and the attention table's rows have no action buttons. Entering the Overview clears the failed banner left over from "the current target": the Overview attributes everything per target (red card + unreachable banner) and no longer stacks a single-target "operation failed" — the same SSH timeout told twice looks like two dead machines. Polling reuses the toolbar's "auto refresh" switch (off by default): each round of this page is one docker ps per target across all N targets, more expensive than any single-target page. With no exceptions it shows "all good"; while some targets have not finished answering, the attention area shows "reading…" and that counter card also enters its loading state (spinner + dimmed, no 0/0/0 — "0 containers" would be read as "this machine has no containers" when it simply has not answered yet) ("don't know yet" is not "all good").

  • Container list: name / status / health / image / port mappings / compose project and service / short ID; it supports search by name or image, filtering by state (running / stopped / all), and auto refresh (polling at pollIntervalSec; switching to the images page pauses it and hides that switch — images change slowly, so there is no point running docker images every 5s; switching back to the containers page restores the previous setting). In the toolbar "include stopped" and "auto refresh" are two grouped switches, and the search box is the only flexible item among them: on a wide panel the whole toolbar collapses into one row, and only in a narrow column (dock, 520px) does it wrap by group — no orphan row holding a single checkbox.

  • "Activity" strip (the docker events stream): a collapsible narrow bar at the head of the container list (expanded by default) that follows one SSE stream (GET /api/dsh-docker/events/stream, where the server runs docker events --filter type=container) showing the last 8 events (time + container name + action, with die carrying its exit code such as die(137)). It is the entry point for event-driven refresh: 500ms after an event arrives it debounces a list refetch (not one request per frame), layering on top of the existing AUTO REFRESH rather than excluding it — polling is the safety net, events cover "just happened". Events stay in memory only (a ring buffer of 50), switching pages closes the stream, and switching targets clears the buffer. The allowlist keeps only the nine lifecycle actions (start / die / stop / kill / oom / health_status / destroy / rename / update): noise such as exec_* and archive-path (docker cp) is dropped server-side — on one batch machine 47 events over 24 hours were all exec with 0 allowlist hits, so on machines that are only ever exec'd the Activity strip is empty, and that is deliberate. A closed stream needs a manual reconnect: entering closed is terminal (EventSource only self-heals connection blips; the docker events process exiting or the server sending an end frame both close it), so the Activity strip offers a Reconnect button (shown only while closed; not in unsupported environments, where reconnecting would just fail again), and the toolbar's ⟳ reconnects as well. The AUTO REFRESH poll deliberately does not reconnect: folding it into the poll would reopen a terminated stream forever, both hiding that it broke and pointlessly creating SSE connections. A successful reconnect compensates with one full list refetch (the list may have fallen behind while the stream was down), including the case where the daemon never came up and the stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking fixed when it is not. A refused reconnect must say why (D164): when the server actively refuses this stream it sends an event: error frame carrying a message (typically the long-stream quota being full — see "Known limitations"; that sentence says what to do about it). The client now shows it on the Activity strip ("Reconnect refused: …") instead of discarding it. It used to just set closed, which made "refused → back to where it was" indistinguishable from "the click did nothing", leaving the user to click over and over. The reason is cleared on a successful reconnect (or when the stream is rebuilt). The hint on the right of that header row is small text inside a single line, so it is deliberately short (2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed, so this only says "will not recover on its own — click Reconnect". The cause, the cap and the MaxSessions details live in this page, not in the UI.

  • Select for merging (temporary multi-select merged logs): the toolbar's Select for merging enters selection mode — a checkbox appears on the left of every card, clicking a card body becomes select / deselect (it no longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" + merged logs + Cancel. merged logs needs at least 2 containers; selecting the 3rd gives a soft hint, and above 3 the button is greyed out with a hint about the cap (where that cap comes from is in "Known limitations" — the browser's per-origin connection budget; the selection bar states it too). Clicking merged logs opens the merged view: it reuses exactly the merged logs of the Compose project view (one /logs/stream per container, mixed by the [service] / container-name prefix, with filtering and auto-scroll), and going back exits selection mode and clears it. The merged view's content controls are fully aligned with the single-container log view (text filter + level threshold + ⬇ .log / ⬇ .md exporting what is displayed + line count), plus two merged-only controls: by time / by arrival ordering and a pause that freezes the view while the streams keep receiving (restoring flushes them in one go). Clicking Select for merging again or pressing Esc likewise exits and clears. Selection is temporary: not persisted, not named into groups, not written to settings; it is dropped when switching targets / switching the "Containers · Images · Compose" segment / closing the panel, and containers that disappeared after a list refresh are pruned by id.

  • One-click selection by condition (in selection mode): the action bar's second row offers a row of condition chips (all visible / unhealthy / needs attention / stopped, plus same image / same project once something is selected), with counts truncated to the remaining slots — a chip reading 3 really does select 3; anything over the cap is stated honestly in the title and the result hint. Conditions only apply within the current filtered result (search / filter the state first, then select in one click).

  • Container cards: "label + value" rows matching the reference layout (image / ID / ports / created / compose, with monospaced truncatable values) plus a row of icon action buttons, split by a vertical rule into the "view / mutate" groups:

    Group Buttons Description
    View / enter Terminal, logs, resource usage Does not change container state; always available even in read-only mode. The Terminal button runs an interactive docker exec -it shell and is not gated by allowExec (that switch only covers this plugin's one-shot exec channel — see "Known limitations")
    Mutate start / stop, restart, remove Ordered by increasing destructiveness; available only with allowMutations on, otherwise the whole group is greyed out

    Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second confirmation after the click. The whole group is locked while a command is in flight: stop / rm waits for the container to actually exit (up to a dozen seconds), during which the confirmation dialog stays open showing "running…" (both buttons disabled), the card's other mutate buttons are dimmed and greyed, the icon that is running becomes a spinner, and everything is restored only once the refreshed list lands — preventing rapid clicking from stacking mutually interrupting commands such as stop + restart + remove. Image removal / pruning goes through the same confirmation dialog and also has a running state. Clicking a card body opens the overview.

  • Container details (a full-column view): the top is "back + container name + status badge + target host", with three tabs below — overview (docker inspect authoritative data + one-shot exec), logs and stats. The log / stats icons on a card land directly on the corresponding tab.

  • Log view (a compact two-row layout): the first row = back + container name + status + target host + LINES (tail line count) / TIMESTAMPS / FOLLOW (live follow, see below) / AUTO REFRESH (a switch plus 2/3/5/10s intervals, polling on the log page only) + refresh / close; the second row = the tabs + an always-present "filter logs" input (an ✕ floats inside to clear when it has content, and Esc clears too) + a level threshold (all / INFO+ / WARN+ / ERROR+ — INFO+ is the "quiet but keep what matters" step: the noise is almost always DEBUG and below, and WARN+ would drop INFO along with it) + export (⬇ .log / ⬇ .md, exporting what is currently displayed) + the line count in a fixed slot on the right. The split between the rows is deliberate: the first row is transport and display (snapshot / stream / polling), the second is content — and the second row is exactly the same as the merged log view: one level kernel, one export builder, one count wording (N lines, or N / M lines while filtered). The level threshold treats a line without a level prefix as a continuation of the previous entry and follows its level — otherwise ERROR+ would cut a stack trace in half. The input's width and position never change, so typing or clearing never nudges this row. The log body is coloured by level (both common prefixes, [INFO] and |INFO, are recognised), timestamps are dimmed, and filter hits are highlighted; beyond 2000 lines only the tail is coloured, with a hint. In the details view the list toolbar and panel header are no longer layered on top, so each screen has exactly one refresh entry point.

  • FOLLOW live log stream: with the log page's FOLLOW switch on, the UI switches from "polling a snapshot" to SSE push (GET /api/dsh-docker/logs/stream, where the server runs docker logs --follow) — new log lines are appended as they arrive and polling stops; FOLLOW and AUTO REFRESH are mutually exclusive (opening the stream stops polling and greys out the switch), and closing it returns to snapshots with an immediate refresh. Streaming logs keep the last 5000 lines / 4MB (a ring buffer that drops the oldest on either cap; newline-free oversized output is force-split so memory stays bounded). Chunks render at most every 150ms (no per-chunk re-render on chatty containers) and rows carry stable ids. The body is windowed (D152): only the visible rows plus 24 rows of overscan on each side are mounted (about 230 nodes for 5000 rows, previously ~10000), the rest is represented by top/bottom padding — under a sustained 20k lines/s flood the max event-loop lag drops from 70–97ms to 6ms and >50ms long frames from 25–26 to 0. Row heights are measured (not estimated) and the window anchors to the tail while pinned, so auto-scroll / scroll-up pause / "back to bottom" keep exact geometry; scrolling up through history is compensated by a top-of-viewport anchor recorded across commits, so it does not jump even while the ring buffer keeps evicting old rows (or the aggregate view inserts a row by timestamp). Switching streams / containers / refreshing a snapshot invalidates that whole generation of measured heights and anchors (D155): snapshot row ids are positional ('s'+index), so the same id is different content after a refresh — keeping them would compute the padding from the wrong generation's heights (measured: scrollHeight inflated by 35644px), so the scrollbar, jumps and "which row is at the top" all describe the wrong generation. The view does not truncate: whatever LINES selects is scrollable and exported — it is simply not all mounted at once (bounded by the buffer and the host output cap). Auto-scroll to bottom, drops the oldest and hints once); filtering / level colouring share exactly the same rendering as snapshots. It auto-scrolls to the bottom, pauses when the user scrolls up and floats a "back to bottom" button; a status line in the top right shows the connection state, and a stream that ends naturally because the container exited switches back to snapshot refresh automatically. Reconnection is managed by the plugin itself (not by EventSource auto-reconnect): the first connection carries tail to backfill history, while every reconnect uses tail=0 — new lines only, never replaying history (auto-reconnect reuses the URL with its tail, so the server pushes the last tail lines again as if they were new, and the log grows a duplicated block). When the host-side backpressure queue (8MB) Host-side backpressure (D153): each stream buffers at most 8MB; the text tail streams (logs, pulls) drop the oldest frames and keep the newest when they overflow, sending a skip{frames,bytes} notice so the UI can say "a stretch is missing, following continues" — the stream is not torn down (the old policy sent end{reason:output-limit} and closed, which left the panel stuck in "connection lost" while the container kept flooding). The skip accounting is exact: when more frames are dropped while a queued skip notice has not been written yet, the server updates that notice in place (D156 — no gap goes unreported); the client accumulates consecutive skips and shows "N batches skipped on this connection", resetting on a successful reconnect (D157). Structured streams (stats, events) still close and reconnect, because a missing sample/event is semantically wrong. Connecting / switching pages / closing the panel all close the EventSource.

  • Overview: docker inspect's authoritative data — state and health, exit code, restart count and policy, port mappings, mounts (including read-only flags), networks and IPs, entrypoint and command, and the latest health-check output; below it you can run a one-shot docker exec (requires allowExec).

  • Logs: a tail snapshot from docker logs --tail (logTailDefault lines by default), with timestamps and --since switchable; output beyond maxOutputKb is truncated and marked. To keep watching new logs, turn on FOLLOW above (the same argv plus --follow, with no overall timeout and no output cap, ending on the connection's lifecycle).

  • Stats: a docker stats --no-stream snapshot (CPU% / memory usage and share / network IO / block IO / PIDs), refreshed by the panel at pollIntervalSec. The stats page also has FOLLOW live following: turning it on switches to GET /api/dsh-docker/stats/stream (the server runs without --no-stream for docker stats, one line per second), and the browser side keeps a 60-point ring buffer to draw CPU / memory mini trend charts (sparklines). Unlike the log stream, this one does not end naturally; its close semantics are the frontend actively aborting the EventSource, and when docker stats exits by itself the server sends end (reason=stats-exit), whereupon the UI hints and switches back to snapshot polling.
  • Compose project view: the toolbar's third segment. It groups the composeProject / composeService labels into "project → service → container", one row per project showing the running / unhealthy / service counts and each container's state; clicking into a project shows the service table, or opens project-level merged logs — one /logs/stream per container in the project, mixed client-side by the [service] prefix (the host-side logsStream already supports arbitrary containers, so no new endpoint is needed), with an auto-scroll switch and a filter box. Merging follows arrival order and does not guarantee strict cross-container ordering.
  • Images: a docker images list (reference / size / created / short ID); <none>:<none> dangling images carry a dangling marker. The search box and the "N / M images" counter are fixed in the toolbar (they do not scroll away with the list), and the header column names stick within the table body. Each row has two actions, "details / remove": details opens the full-column view (overview: size / size including parents / created / platform / layers / entrypoint and command / exposed ports / digest / labels; build history: the per-layer commands and sizes from docker history). Remove (requires allowMutations) runs docker image rm after a second confirmation (without -f, so an image that is referenced fails with a hint to "remove the related containers first").
  • Networks / volumes (the fifth and sixth segments): the toolbar segments extend to "Containers / Images / Compose / Networks / Volumes" (when a narrow column cannot fit them, the segment container scrolls horizontally by itself, with no second-level menu). Both pages use the same "table + full-column details" layout:
    • Networks: name / driver / scope / internal badge / ID, with a row click opening details — overview (ID / driver / scope / created / subnet / gateway / internal·attachable·ingress·ipv6 / options / labels) and a "connected containers" tab (container / IPv4 / IPv6 / MAC). Container counts deliberately stay out of the list rows: only docker network inspect returns the connected list, and inspecting every row would be N docker calls, so it is fetched once on entering details instead.
    • Volumes: name / driver / scope / mountpoint (over-long paths are width-limited and truncated, with the full value in title); details are name / driver / scope / mountpoint / created / options / labels (volumes have no reverse index, so there is only the overview page).
    • The details header has a remove button, and both toolbars have a prune icon, all gated by allowMutations (greyed out with an explanatory title when off) and all requiring a second confirmation; a failed removal (a network still has containers attached / a volume is still in use / 403) shows an inline banner on the details page rather than failing silently.
    • Auto refresh matches the images page: these two pages do not poll (inventories change slowly, and a docker CLI call every 5s is burnt for nothing); they refresh only when switching pages or targets.
  • Pulling images (an SSE progress stream): the pull icon in the images toolbar (requires allowMutations) opens the pull view; after entering a reference it goes through GET /api/dsh-docker/images/pull/stream (where the server runs docker pull) — per-layer progress (Pulling fs layer / Downloading / Extracting / Pull complete) appears live; progress lines are updated in place keyed by "layer key", and TTY \r refreshes do not make the buffer grow ever longer. The toolbar's "prune dangling" runs docker image prune -f, which only removes untagged images (deliberately without --all, to avoid deleting ordinary unused images by mistake).
  • One-shot exec: with allowExec on you can type a command, equivalent to docker exec <container> sh -c "<command>", returning the exit code and stdout/stderr (no TTY).

  • Interactive terminal (the first icon on a card): it runs docker exec -it '<container>' sh. It degrades in three steps depending on tty's capabilities —

    1. Embedded in place (tty ≥ 0.15, recommended): a terminal drawer opens at the bottom of the panel, and tty's ttyTerminal.mount mounts the terminal into it. The panel does not collapse, so you can watch the container's logs and step into the container to type commands without losing context. The drawer takes height away from the body, so it can give way without ending the session: collapsing (the arrow at the right of its title bar, or double-clicking its top edge) squashes the drawer into one title bar while the session keeps running; dragging the top edge resizes it (capped at 75% of the panel height). Only two actions really end a session — the ✕ on the drawer and closing the panel; with an active session, closing the panel asks for confirmation first, so that clicking the blank backdrop does not kill a container shell that is mid-troubleshooting (the global terminal panel also treats "clicking the blank area = minimize", the same stance).
    2. Open a tab through tty (tty ≥ 0.14): tty opens a new tab running the same command, and this panel then collapses (this panel has a higher z-index, so not collapsing would only make the user feel "nothing happened").
    3. Copy the command: when tty is not installed / too old / an inline target uses key·password auth (the browser has no credentials), it degrades to copying the command with a hint to paste it into the terminal panel.

    Local targets open a local session, while SSH targets go over SSH by connection-book entry name (or by the inline fields authenticated by the agent), and the tab / drawer title is <container> · exec. Capability detection goes through the service contract version (mount exists only with ttyTerminal.version >= 2), not by guessing whether a function exists.

Targets (local / SSH)

kind Description
local the docker CLI on the machine hosting the plugin (spawn runs it directly, not through a shell)
ssh connects to a remote host over ssh2 and runs the docker CLI remotely (argv single-quote escaped)

There are two ways to fill in kind=ssh:

  1. Reference a tty connection-book entry: put the entry name in book (maintained in the connection book under Settings → Plugins → Terminal Panel), and the host / port / username / auth method follow from it. The settings card's dropdown only lists connection-book entries tty has saved; if tty is not installed or the entry does not exist, that target fails to resolve and both the panel and the agent tools report a clear error.
  2. Inline fields: host + username are required, the rest as needed (port / auth / keyPath / password / passphrase / agentForward).

Targets are resolved fresh for every operation: if you change a connection-book entry in the tty card (a different port, a new password), the next operation uses the new value immediately with no restart. The remote side must satisfy: the docker CLI is installed, and the current account can use docker without sudo (usually because it is in the docker group); otherwise probe passes through errors such as permission denied while trying to connect to the Docker daemon socket verbatim.

Connect local (one click)

If you would rather not fill in a target by hand, the UI offers two entries, neither of them permanent:

When Where
You have no targets at all A Connect local primary button in the panel's empty state (the whole panel is blank then, so one click is the shortest path)
You already have other targets The last item in the target dropdown, + Connect local — right where you look for a machine, and only visible once you open it

The two are mutually exclusive (the same entry never appears twice): with no targets you use the big button in the empty state; with targets, the dropdown carries the item. Once a local target exists neither appears (it is already in the selector, so listing it again would just be a duplicate).

On the agent side the same operation is docker_connect_local, and over HTTP it is POST /connect-local — every entry point shares one implementation on the host.

Why not a permanent toolbar button: a permanent "Connect local" both occupies the spot right of the target selector and has to attract clicks with an accent colour, while being pure noise once you have a usable target (the first version was permanent, and the user called it out as redundant on sight). But keeping only the empty-state entry would miss the common case of "I have a few SSH hosts and want to add this machine too" — so the second entry lives in the dropdown, appearing only when you are genuinely looking for a machine.

Situation Behavior
A kind=local target already exists (whatever its name) Reuse it (created:false) and just switch to it; nothing is created, rewritten or renamed
No local target Append one under the stable name local; if local is taken by another target, try local-2, local-3, … (deterministic, conflict-free, never overwriting)
SSH / custom targets configured Left exactly as they are (order and credentials included); only the local entry is appended at the end
Clicked twice / concurrently Idempotent: exactly one local target in the end
Settings store unavailable / save fails A clear error (503 / 500 with the reason) and no in-memory config change — never "it showed up in the UI but is gone after a refresh"

After the write succeeds it also runs one read-only probe (docker version) and puts the result into message: the server version when the daemon is reachable, or the actual cause when it is not — docker CLI missing (install Docker, or point "docker CLI" at the right path) / probe timed out (the daemon is wedged, or docker context points at an unreachable remote; check with docker context ls) / daemon not running (Linux: systemctl start docker; macOS / Windows: start Docker Desktop) / no permission on the socket (add the account to the docker group) — instead of just "no containers". A failed probe is not a failed operation (the target really was added; reachable:false says so separately), and it works as soon as the daemon comes up.

The binary this probe uses is exactly the "docker CLI" value from the settings card (dockerBin, docker by default). When that CLI is missing it does one read-only lookup for another container CLI on PATH (docker → podman → nerdctl) and names it in the message ("t

…

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.