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 ★ 44 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.
  • 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.

  • 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; at 7–8 selected it gives a soft hint (browsers limit same-origin concurrent long connections), and above 8 the button is greyed out with a hint about the cap. 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 8 really does select 8; 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
    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.

Merged logs (multi-select / Compose project)

Selecting several containers or opening a Compose project can both merge the logs of several containers into one stream (one docker logs -f SSE per container, mixed client-side in arrival order). The toolbar offers:

Control Semantics
Live / Paused a real pause (see below)
Timestamps shows a timestamp on every line. Timestamps are always received with the stream (timestamps=1); this only affects display
By arrival / By time by arrival: follows with zero delay; by time: merges into one true timeline using each line's container timestamp
Level filter all levels / WARN+ / ERROR+. Lines with no level prefix (continuation lines such as stack traces) inherit the level of the previous log entry, so ERROR+ keeps its stack trace with it and INFO continuations are filtered out together with their first line; orphan continuation lines at the start of the window (whose record header is outside the window) cannot be judged and are kept
⬇ .log / ⬇ .md exports what is currently displayed: .log is plain line text ([service] ISO time body), while .md carries a header with the source containers / line count / export time and can be attached to a ticket directly

How merging by time works: the SSE connections for the various containers are established at different moments, so A's initial backlog may arrive in one batch while B's arrives later, and sorting each batch on its own cannot fix cross-batch inversions. The implementation backfills the last 400 lines — whenever a new batch of lines arrives it re-sorts "the last 400 lines + the new lines" by timestamp (using the RFC3339 prefix from docker logs --timestamps as the sort key, which is parsed and then stripped from the body). That way it neither stalls for a while to get the first screen right (no one-second blank page on open) nor fails to correct historical misordering after the fact; the price is that in "by time" mode the last few lines already on screen may shift slightly (use "by arrival" while following live output).

"Pause" in merged logs (a real pause)

The merged logs of a multi-select / Compose project have a Live / Paused switch. Pausing freezes the content, not just auto-scroll:

  • while paused, newly arriving logs go into a client-side buffer and the DOM is no longer appended to — the screen reader is not pushed away, and the view does not jump when the display cap (2000 lines) trims the front;
  • the button itself shows how many lines have accumulated (Paused +348);
  • on resume the buffer is merged in one go (still under the 5000-line ring cap) and the view returns to the bottom.

Only stopping auto-scroll is not enough: with the label saying "paused" while the content keeps growing, users think the switch is broken; and with a high log volume the view also jumps by itself as the front is trimmed.

Overview (cross-target) and the "needs attention" criteria

The "Overview" page lays out all targets on one screen: one counter card per target (running / stopped / unhealthy / needs attention), and below it a cross-target "containers needing attention" table (container / target / status / reason / image; click a row for details, click a card to switch to that target's list). Three design constraints:

  • Progressive landing + failure isolation: each target requests and lands independently; one unreachable SSH host does not silence the whole page — the unreachable target gets its own banner and the other targets' results remain available.
  • The "needs attention" criteria are the host's: the container list and /attention are requested in parallel; the latter does an extra docker inspect, so it can identify OOM (OOMKilled) and the real exit code — a ps summary's Exited (137) cannot tell an OOM kill from a manual kill. When /attention is unavailable (an older host / that target failed) it falls back to the summary criteria and labels the count "needs attention (rough)".
  • Ordering: OOM > zombie > unhealthy > repeatedly restarting > non-zero exit; equal weights are ordered by "most recent end time" descending, so the freshest crash is at the top (hovering a row shows the end / start times, restart count and exit code).

Configuration (Settings → Plugins → "Docker Container Panel", saved and applied hot)

Where the settings surface lives depends on the DSH version, but it is always the same form (same settings namespace, same write path) — the data and behaviour are identical:

Host Where the settings are
0.2.0-rc.1 and later Directly below the description on the plugin detail page (plugins.bundle.config, key = this package's own bundle name) — no extra ">" step into a sub-page
0.1.6 line Plugins sidebar → the bundle's row → the ">" row detail (plugins.row.config)
≤0.1.5 Settings → Plugins → the plugin's collapsible card (settings.plugin.item)
All of the above Settings → plugin configuration, the same entry (settings.kit.item, provided by @hyzyn/dsh-kit-settings)

On hosts that have the bundle slot, this package's row entry is no longer registered — two entries for one form only makes people think there are two sets of settings; hosts without it (the bundle slot missing) fall back to the row detail automatically, with no loss of function. The aggregate bundle @hyzyn/dsh-all is the exception: its plugins.bundle.config key is shared by every plugin (a second registrant throws), so it has no inline slot and keeps using its row entry.

Configuration lives in the settings namespace docker, i.e. the docker: section of ~/.dsh/settings.yaml ($DSH_HOME/settings.yaml; DSH's settings file is provided by the host's dsh-settings-file). The composition config in the plugin line acts as the schema's base, which the settings layer overrides; the HTTP POST /api/dsh-docker/config is the card's write channel and accepts only the keys in the table below (unknown keys return 400).

Item Default Description
enabled true disables the whole plugin (takes effect on save: tools are unregistered immediately,

…

Content from the project README on GitHub ↗

Comments

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