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 throughdocker_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 adocker pssummary 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 eventsanddocker pullall run through the sameopenSseStream(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--timestampsprefix; "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 thettyPanelv2 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 readscurrentoff thesessions.listsnapshot, while since 0.1.6 that field moved out of the session domain together with view selection, so it readsretainedBy.mainView > 0instead (the same testdsh-client-ui-sessionand 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-allor the root bundle in the web profile, do not add this package again, or the plugin line is mounted twice and startup reportsduplicate 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), borderaccent 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 intitle, 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
targetsconfig, local / SSH); with only one target configured it is selected by default, and agent tools may also omit thetargetparameter. The last selected target is remembered (stored in the browser'slocalStorageunderdsh-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
Overviewpill 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 ownPOST /containers(all:true, since a baredocker psdoes 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 withPromise.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 onedocker psper 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 runningdocker imagesevery 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 runsdocker events --filter type=container) showing the last 8 events (time + container name + action, withdiecarrying its exit code such asdie(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 existingAUTO REFRESHrather 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 asexec_*andarchive-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 mergingenters 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 logsneeds 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. Clickingmerged logsopens the merged view: it reuses exactly the merged logs of the Compose project view (one/logs/streamper 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/⬇ .mdexporting 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). ClickingSelect for mergingagain 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, plussame image / same projectonce 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 allowMutationson, otherwise the whole group is greyed outRemove 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/rmwaits 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 inspectauthoritative 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, andWARN+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, orN / M lineswhile filtered). The level threshold treats a line without a level prefix as a continuation of the previous entry and follows its level — otherwiseERROR+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
FOLLOWswitch on, the UI switches from "polling a snapshot" to SSE push (GET /api/dsh-docker/logs/stream, where the server runsdocker logs --follow) — new log lines are appended as they arrive and polling stops;FOLLOWandAUTO REFRESHare 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:scrollHeightinflated by 35644px), so the scrollbar, jumps and "which row is at the top" all describe the wrong generation. The view does not truncate: whateverLINESselects 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 carriestailto backfill history, while every reconnect usestail=0— new lines only, never replaying history (auto-reconnect reuses the URL with itstail, so the server pushes the lasttaillines 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 askip{frames,bytes}notice so the UI can say "a stretch is missing, following continues" — the stream is not torn down (the old policy sentend{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 queuedskipnotice 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 theEventSource.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-shotdocker exec(requiresallowExec).Logs: a tail snapshot from
docker logs --tail(logTailDefaultlines by default), with timestamps and--sinceswitchable; output beyondmaxOutputKbis truncated and marked. To keep watching new logs, turn onFOLLOWabove (the same argv plus--follow, with no overall timeout and no output cap, ending on the connection's lifecycle).
- Stats: a
docker stats --no-streamsnapshot (CPU% / memory usage and share / network IO / block IO / PIDs), refreshed by the panel atpollIntervalSec. The stats page also hasFOLLOWlive following: turning it on switches toGET /api/dsh-docker/stats/stream(the server runs without--no-streamfordocker 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 theEventSource, and whendocker statsexits by itself the server sendsend(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/composeServicelabels 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/streamper container in the project, mixed client-side by the[service]prefix (the host-sidelogsStreamalready 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 imageslist (reference / size / created / short ID);<none>:<none>dangling images carry adanglingmarker. 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 fromdocker history). Remove (requiresallowMutations) runsdocker image rmafter 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 inspectreturns 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
dockerCLI call every 5s is burnt for nothing); they refresh only when switching pages or targets.
- 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
- 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 throughGET /api/dsh-docker/images/pull/stream(where the server runsdocker 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\rrefreshes do not make the buffer grow ever longer. The toolbar's "prune dangling" runsdocker image prune -f, which only removes untagged images (deliberately without--all, to avoid deleting ordinary unused images by mistake).
One-shot exec: with
allowExecon you can type a command, equivalent todocker 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 —- Embedded in place (tty ≥ 0.15, recommended): a terminal drawer opens at the bottom of the panel, and
tty's
ttyTerminal.mountmounts 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). - 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").
- 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 (mountexists only withttyTerminal.version >= 2), not by guessing whether a function exists.- Embedded in place (tty ≥ 0.15, recommended): a terminal drawer opens at the bottom of the panel, and
tty's
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:
- 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. - Inline fields:
host+usernameare 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
/attentionare requested in parallel; the latter does an extradocker inspect, so it can identify OOM (OOMKilled) and the real exit code — apssummary'sExited (137)cannot tell an OOM kill from a manual kill. When/attentionis 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, |
…
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.