dsh-compose-panel
English | 中文
A DeepSeek Harness plugin
that puts your Docker Compose projects in the native right sidebar: one
group per project directory, live status per container, start/stop/restart on
hover, and a streaming log tab per service.
It is built for the workspace layout that a single-file container viewer gets
wrong: one repository whose subdirectories each own a compose.yaml.
工作区 ~/work/shop
├── compose.yaml → group “shop” 2/2 running
├── services/api/compose.yaml → group “services/api” 1/3 running
└── tools/mail/compose.yaml → group “tools/mail” not started
Features
- One group per project directory. Projects are keyed by directory, never
by the compose project name, so
-p, COMPOSE_PROJECT_NAME and .env cannot
merge two different stacks into one row.
- Three discovery sources. Running and stopped containers, projects Docker
already knows but that hold no container, and compose files never brought up
at all — all three appear, so an untouched
compose.yaml still shows as a
project that is simply not started.
- Workspace scoped. Only projects inside a DSH workspace are listed.
Containers that belong to a workspace-relative directory are grouped under the
longest-matching workspace; everything else is counted, not hidden.
- Collapsible groups, status on the right, fixed row heights (nothing moves
when the pointer crosses a row).
- Hover actions. Resting on a row swaps its status cell for the actions that
apply to it: project-wide
up -d / restart / stop, or the same three for
one service. Nothing is ever run without a click.
- Live logs, streamed. Click a service row to open a log tab for that
service; click a project header to open one for the whole project. Each target
keeps its own tab.
- Silent refresh. The list re-reads itself every 45s while idle and every 8s
while something is still starting, without losing scroll position, open
groups or the selection.
- Product-native styling: it uses the harness' own theme tokens, so it follows
light and dark mode and the accent colour without a settings screen.
Requirements
| Requirement | Notes |
|---|
| DSH with a web profile | The plugin registers a right-sidebar tab type and one HTTP route, both served by webServer. |
Docker CLI on PATH for the host process | docker ps and docker compose ls are run as child processes; the compose v2 plugin (docker compose, not docker-compose) is required. |
| A POSIX host | Paths are handled as POSIX paths; Windows is untested. |
| A DSH workspace | Only directories inside a registered workspace are listed. |
Install
Straight from GitHub — nothing to clone (pin a tag if you want a fixed release):
dsh plugin --profile web add "github:yizhixiaokong/dsh-compose-panel"
# pinned: dsh plugin --profile web add "github:yizhixiaokong/dsh-compose-panel#v0.1.1"
From a clone, when you want to edit the code:
git clone https://github.com/yizhixiaokong/dsh-compose-panel.git
cd dsh-compose-panel
dsh plugin --profile web add "$PWD"
From npm, once it is published:
dsh plugin --profile web add dsh-compose-panel
All three end the same way, and none of them needs a manual registration step:
dsh plugin runs pnpm, then reads each installed dependency's
package.json; a package that declares dsh.bundle has its name appended to
dsh.profile.bundles, which is what mounts the
cordis.patch.yml beside it. (A dependency without that
declaration is installed as a plain library and warns instead.) There is no
build step either — the client half is a hand-written bundle that ships in
lib/.
Then restart the harness so the client bundle is picked up:
dsh web
To remove it:
dsh plugin --profile web remove dsh-compose-panel
The client half is baselined when the host boots. Editing
lib/client.js in a running harness changes nothing until dsh web
restarts — a browser refresh is not enough.
Usage
- Open the right sidebar and pick the 🐳 容器 capsule from the guide page.
- Each workspace is a section; each project directory is a collapsible group
headed by its path, with the aggregate container state on the right.
- Hover a project header to act on the whole project; hover a container row to
act on that one service. The action buttons replace the status cell while the
pointer is on the row, and the result is reported in a short banner.
- Click a container row (not a button) to open logs for that service in a
new tab. Click the group header to open logs for the whole project.
Log tabs have their own controls: replay size (tail), whether to re-open the
stream, timestamps on/off, line wrapping, and — for a whole-project tab — the
service-name column.
How projects are discovered
One list call runs three commands and joins their results by directory:
| Source | Command | Contributes |
|---|
| Containers | docker ps -a --format '{{json .}}' | Every container, grouped by the com.docker.compose.project.working_dir label. |
| Known projects | docker compose ls --all --format json | Projects that exist but currently have no container (its ConfigFiles are absolute). |
| Untouched files | one bounded find per workspace | Compose files that were never brought up: depth 4, with node_modules, .git, vendor, dist, build, target, .venv, venv, __pycache__, .next, .cache and .tox pruned. |
A project's directory is the join key. Compose file names recognised by the scan
are compose.yml, compose.yaml, docker-compose.yml, docker-compose.yaml
and the compose.<name>.y*ml / docker-compose.<name>.y*ml variants.
HTTP API
The host half registers one prefix route, /compose/api. Every response uses the
same envelope: {"ok":true,"value":…} on success, {"ok":false,"error":{"code","message"}}
on failure.
Every call is fenced before any command runs. For the two POST methods the
Host header must name a loopback authority (localhost, 127.0.0.1, ::1,
*.localhost, with or without a port) or an authority in the web runtime's
trusted list; a request the browser marks Sec-Fetch-Site: cross-site is
refused; and if an Origin is present its host must equal Host. The log
stream uses the read-side variant: the socket peer must be loopback (or the
Host trusted), no forwarding header (forwarded, x-forwarded-for,
x-real-ip) may be present, and a missing Origin is accepted — browsers send
none on a same-origin EventSource.
POST /compose/api/list
// request — root is optional; without it every workspace is listed
{ "root": "/home/me/work/shop" }
// response value
{
"workspaces": [{ "path": "/home/me/work/shop", "title": "shop" }],
"groups": [{
"root": "/home/me/work/shop",
"title": "shop",
"scanError": "",
"projects": [/* … */]
}],
"total": 7, // containers inside the selected workspaces
"outside": 2, // containers found but outside every workspace
"standalone": 1, // containers with no compose project label
"notices": []
}
POST /compose/api/action
{
"action": "restart", // "up" | "stop" | "restart" — a closed set
"dir": "/home/me/work/shop", // the project directory, must be in a workspace
"service": "api", // optional; omitted = the whole project
"files": ["/home/me/work/shop/compose.yaml"] // optional compose files (-f)
}
// response value; `code` is docker's exit code, `output` its combined output
{ "action": "restart", "dir": "…", "service": "api", "code": 0, "output": "" }
There is deliberately no down: removing containers and volumes is not a
sidebar-click operation. service is matched against
^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$, and every -f path must lie inside dir.
argv is passed to spawn directly and never goes through a shell.
GET /compose/api/logs (Server-Sent Events)
| Query | Meaning |
|---|
dir | Project directory (required, must be in a workspace). |
service | One service; omitted streams the whole project. |
tail | Lines replayed first: default 200, clamped to 1…5000. |
timestamps | 1 adds --timestamps; off by default. |
file | Compose file (-f), repeatable. Without it the host derives the files from docker compose ls and, failing that, a shallow find. |
Frames are data: JSON objects:
{ "open": true, "command": "docker compose -f … logs -f --tail 200 api" }
{ "out": "api-1 | listening on :3000\n" } // stdout chunk, already UTF-8 safe
{ "err": "…" } // stderr chunk, same shape
{ "eof": true, "code": 0 } // docker exited
{ "error": "spawn failed: …" }
The stream carries NO_COLOR=1, sends a comment ping every 15s so a dead peer
is noticed, stops after 4 MB, and terminates the docker process as soon as the
tab closes, reloads or navigates away — logs -f never ends on its own.
Limits and known behaviour
- Read-only elsewhere. Only
up -d, stop and restart are reachable;
nothing else in the sidebar mutates state.
- Label parsing.
docker ps returns the compose project labels as a
comma-separated string; compose labels never contain a comma, so splitting on
, is safe here.
- Log prefix. Docker's own
<service> | prefix stays in a project-wide log
tab and is hidden by default in a single-service tab, where it only repeats
the tab title.
- First paint. The very first
list shells out to find once per workspace;
on a very large tree the panel can take a moment to fill in. Later refreshes
reuse the last result.
- No shell plugin. Container state comes from the Docker CLI, so a stopped
Docker daemon shows up as an error notice rather than an empty panel.
Development
lib/index.js host half — routes, discovery, docker invocations
lib/client.js client bundle — sidebar tab, list, log viewer
cordis.patch.yml the single host row this package contributes
scripts/smoke.mjs offline checks (no Docker, no harness, no network)
npm test # node scripts/smoke.mjs
The smoke test materializes the client bundle against stub globals and applies
the host half to a stub context. It pins the two failures that are expensive to
reach live: a factory binding read before its declaration (which throws during
profile boot and unloads every later plugin row) and a changed set of sidebar
seats or routes.
After changing lib/client.js or lib/index.js, restart dsh web — the client
bundle is baselined at boot.
Repository notes
Versions are tagged to match package.json — this tree is v0.1.1.
The yizhixiaokong placeholder in package.json, CHANGELOG.md, the badges and the
install commands above stands for the GitHub account this repository is pushed
to; replace it before publishing. The commits here were authored under a neutral
dsh-plugins <noreply@example.com> identity so that no personal address ends up
in the published history; if you want your own name on them, set
git config user.name / user.email and run
git commit --amend --reset-author before pushing.
Getting listed in the marketplace
The plugin list at
awesome-dsh-plugin
is generated from one YAML file per plugin, and its CI checks a submission's
shape against this repository. The mechanical part is already satisfied here:
dsh.bundle is declared in package.json beside cordis.patch.yml, the
official @deepseek-ai/* packages are declared as peerDependencies (this
package imports none of them, but it is a Cordis plugin), there are no
dependencies to install and no build step. What is left belongs to the
repository owner:
-
add the dsh-plugin topic —
gh repo edit yizhixiaokong/dsh-compose-panel --add-topic dsh-plugin;
-
let the repository age past 1 day (the list's CI rejects younger ones);
-
open one PR adding data/plugins/yizhixiaokong__dsh-compose-panel.yml:
url: https://github.com/yizhixiaokong/dsh-compose-panel
name: yizhixiaokong/dsh-compose-panel
category: dev
description:
en: 'Lists the Docker Compose projects of your DSH workspaces in the right sidebar, one group per project directory, with container status, project- and service-level up/stop/restart, and streamed per-service logs.'
zh: '在右侧栏列出各 DSH 工作区中的 Docker Compose 项目,按项目目录分组,显示容器状态,支持项目级与服务级 up/stop/restart 以及单服务日志流。'
The storefront reads a screenshots.json next to package.json (1–8 images)
for its screenshot strip; this repository declares assets/01-projects.png,
assets/02-actions.png and assets/03-logs.png, in that order.
Publishing to npm
npm login --registry=https://registry.npmjs.org # a mirror accepts neither a login nor a publish
npm publish --registry=https://registry.npmjs.org
npm publish --dry-run prints the tarball contents without uploading anything,
and prepublishOnly runs npm test before either. The published package's
repository field points back here; that is what lets the plugin list link the
two and then offer the registry install form instead of the GitHub one.
License
MIT