opencode-docker-panel 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -5
- package/README.ru.md +9 -5
- package/dist/events.js +103 -0
- package/dist/panel.js +4 -3
- package/dist/poll.js +75 -10
- package/dist/tui.js +0 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to this plugin, by version and date. The version is the one in `package.json` at
|
|
4
4
|
that commit.
|
|
5
5
|
|
|
6
|
+
## 0.6.0 - 2026-10-10
|
|
7
|
+
|
|
8
|
+
- The panel follows `docker events` instead of asking on a timer, so a container appears or disappears
|
|
9
|
+
as soon as Docker reports it: 442 ms on a measured `compose stop`, against up to three seconds before
|
|
10
|
+
- Only `start`, `die`, `destroy`, `pause`, `unpause` and `rename` cause a repaint. `kill`, `stop` and
|
|
11
|
+
`create` would redraw a frame identical to the one already on screen, and `exec_` and `health_` events
|
|
12
|
+
are the container's own internals
|
|
13
|
+
- Polling stays as a safety net: `docker ps` every 30 seconds, and every 3 seconds while Docker is
|
|
14
|
+
stopped so that starting Docker Desktop from the tray shows up at once
|
|
15
|
+
- The event stream is reopened when its process exits, which it does loudly: about thirty seconds of
|
|
16
|
+
silence, then `unexpected EOF` and exit code 1. Reopening waits for `docker ps` to answer, so no
|
|
17
|
+
process is spawned for as long as Docker stays down
|
|
18
|
+
- `intervalMs` is gone. It controlled how often the panel ran `docker ps`, and events make that
|
|
19
|
+
meaningless; leaving it in a config is harmless
|
|
20
|
+
- Two container changes in quick succession no longer lose the second one, and simultaneous refreshes
|
|
21
|
+
can no longer apply out of order
|
|
22
|
+
- `Stop Docker Desktop` hides the `Up stack` line the moment it is pressed rather than after the
|
|
23
|
+
command finishes
|
|
24
|
+
|
|
6
25
|
## 0.5.1 - 2026-10-08
|
|
7
26
|
|
|
8
27
|
- The repository is formatted with prettier, with a config that matches the existing style: no semicolons,
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ whatever is already in it.
|
|
|
35
35
|
```jsonc
|
|
36
36
|
{
|
|
37
37
|
"$schema": "https://opencode.ai/v2/cli.json",
|
|
38
|
-
"plugins": [
|
|
38
|
+
"plugins": ["opencode-docker-panel"],
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
@@ -49,7 +49,7 @@ Two things trip people up here, so they are worth stating plainly:
|
|
|
49
49
|
Restart the TUI afterwards. The host installs the package on the next start; nothing to copy and
|
|
50
50
|
nothing to build.
|
|
51
51
|
|
|
52
|
-
Pin a version when you want a known state: `{ "package": "opencode-docker-panel@0.
|
|
52
|
+
Pin a version when you want a known state: `{ "package": "opencode-docker-panel@0.6.0" }`.
|
|
53
53
|
|
|
54
54
|
### Verification
|
|
55
55
|
|
|
@@ -71,9 +71,13 @@ If the header never appears, the plugin did not load: check that the entry is in
|
|
|
71
71
|
|
|
72
72
|
## Options
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
There are none. The panel follows Docker's own event stream, so there is nothing to configure and
|
|
75
|
+
nothing to tune. It still polls as a safety net every 30 seconds, and every 3 seconds while Docker is
|
|
76
|
+
stopped so that starting Docker Desktop from the tray shows up right away.
|
|
77
|
+
|
|
78
|
+
Before `0.6.0` there was an `intervalMs` option. It controlled how often the panel ran `docker ps`,
|
|
79
|
+
and it no longer does anything: events carry the updates and the poll is only a net. Leaving it in
|
|
80
|
+
your config is harmless.
|
|
77
81
|
|
|
78
82
|
Everything else lives in [docs/features.md](docs/features.md).
|
|
79
83
|
|
package/README.ru.md
CHANGED
|
@@ -33,7 +33,7 @@ https://github.com/victor-ochenin/opencodeDockerPlugin#installation
|
|
|
33
33
|
```jsonc
|
|
34
34
|
{
|
|
35
35
|
"$schema": "https://opencode.ai/v2/cli.json",
|
|
36
|
-
"plugins": [
|
|
36
|
+
"plugins": ["opencode-docker-panel"],
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
@@ -44,7 +44,7 @@ https://github.com/victor-ochenin/opencodeDockerPlugin#installation
|
|
|
44
44
|
|
|
45
45
|
После этого перезапусти TUI: хост сам поставит пакет при следующем старте, копировать и собирать ничего не нужно.
|
|
46
46
|
|
|
47
|
-
Если нужна зафиксированная версия, укажи её явно: `{ "package": "opencode-docker-panel@0.
|
|
47
|
+
Если нужна зафиксированная версия, укажи её явно: `{ "package": "opencode-docker-panel@0.6.0" }`.
|
|
48
48
|
|
|
49
49
|
### Проверка
|
|
50
50
|
|
|
@@ -61,9 +61,13 @@ CLI-проверки у этого плагина нет: он рисуется
|
|
|
61
61
|
|
|
62
62
|
## Опции
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Их нет. Панель следует за потоком событий самого Docker, поэтому настраивать и подкручивать нечего.
|
|
65
|
+
Опрос при этом остался как страховка: раз в 30 секунд и раз в 3 секунды, пока Docker выключен, чтобы
|
|
66
|
+
запуск Docker Desktop из трея был виден сразу.
|
|
67
|
+
|
|
68
|
+
До `0.6.0` была опция `intervalMs`. Она задавала, как часто панель запускает `docker ps`, и больше
|
|
69
|
+
ничего не делает: обновления приходят по событиям, а опрос только страхует. Если она осталась у тебя
|
|
70
|
+
в конфиге, это безвредно.
|
|
67
71
|
|
|
68
72
|
Всё остальное — в [docs/features.ru.md](docs/features.ru.md).
|
|
69
73
|
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const COMMAND = "docker";
|
|
3
|
+
const ARGS = ["events", "--filter", "type=container", "--format", "{{json .}}"];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The actions that can change what the panel draws.
|
|
7
|
+
*
|
|
8
|
+
* `kill` is left out because it arrives as a pair ahead of `stop` and `die` and changes nothing on its
|
|
9
|
+
* own. `create` is out because the container stays `created` for the half second before `start` and the
|
|
10
|
+
* panel draws no row for that state. `stop` is out because `docker ps` already reports `exited` by the
|
|
11
|
+
* time it arrives. `restart` is out because a measured `docker restart` sends `die` and `start` first,
|
|
12
|
+
* which bracket the whole visible change, so it would repeat the frame `start` just produced.
|
|
13
|
+
* Everything with an `exec_` or `health_` prefix is the container's internals and never reaches the panel.
|
|
14
|
+
*/
|
|
15
|
+
export const INTERESTING_ACTIONS = ["start", "die", "destroy", "pause", "unpause", "rename"];
|
|
16
|
+
/** Returns the container name an event is about, or null when the panel has no reason to react */
|
|
17
|
+
export function parseEventLine(line) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed) return null;
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(trimmed);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
27
|
+
const event = raw;
|
|
28
|
+
if (event.Type !== "container") return null;
|
|
29
|
+
const action = String(event.Action ?? event.status ?? "");
|
|
30
|
+
if (!INTERESTING_ACTIONS.includes(action)) return null;
|
|
31
|
+
const name = event.Actor?.Attributes?.name;
|
|
32
|
+
return typeof name === "string" && name.length > 0 ? name : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A long-lived `docker events` reader.
|
|
36
|
+
*
|
|
37
|
+
* The stream ends loudly rather than going quiet: stopping Docker Desktop leaves it silent for about
|
|
38
|
+
* thirty seconds and then writes `unexpected EOF` and exits with code 1. So the caller is told when the
|
|
39
|
+
* process is gone and decides on its own when reopening is worth the attempt.
|
|
40
|
+
*/
|
|
41
|
+
export function createEventWatch() {
|
|
42
|
+
let child;
|
|
43
|
+
let buffer = "";
|
|
44
|
+
let closed = false;
|
|
45
|
+
let consumer;
|
|
46
|
+
let onDead;
|
|
47
|
+
const open = () => {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
const spawned = spawn(COMMAND, ARGS, {
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
52
|
+
});
|
|
53
|
+
child = spawned;
|
|
54
|
+
// A docker that is not on the PATH makes spawn emit an error, and an unheard one throws
|
|
55
|
+
spawned.on("error", () => {});
|
|
56
|
+
// A half-written line from the killed process would prepend itself to the new process's first
|
|
57
|
+
// chunk and take that first event down with it
|
|
58
|
+
buffer = "";
|
|
59
|
+
spawned.stdout?.setEncoding("utf8");
|
|
60
|
+
spawned.stdout?.on("data", chunk => {
|
|
61
|
+
// The last split piece is a partial line and has to survive to the next chunk
|
|
62
|
+
const lines = (buffer + chunk).split("\n");
|
|
63
|
+
buffer = lines.pop() ?? "";
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!parseEventLine(line)) continue;
|
|
66
|
+
consumer?.();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// Only the process still in hand gets to speak for the stream, and killing one is asynchronous, so
|
|
70
|
+
// after a restart the previous child still reports in. Trusting that report would drop the live
|
|
71
|
+
// child's reference, orphan the process and fake a dead stream. The same check covers our own stop,
|
|
72
|
+
// which clears `child` before killing.
|
|
73
|
+
spawned.on("close", () => {
|
|
74
|
+
if (child !== spawned) return;
|
|
75
|
+
child = undefined;
|
|
76
|
+
onDead?.();
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const stop = () => {
|
|
80
|
+
const current = child;
|
|
81
|
+
child = undefined;
|
|
82
|
+
current?.kill();
|
|
83
|
+
};
|
|
84
|
+
open();
|
|
85
|
+
return {
|
|
86
|
+
onEvent: onChange => {
|
|
87
|
+
consumer = onChange;
|
|
88
|
+
},
|
|
89
|
+
onDead: handler => {
|
|
90
|
+
onDead = handler;
|
|
91
|
+
},
|
|
92
|
+
restart: () => {
|
|
93
|
+
stop();
|
|
94
|
+
open();
|
|
95
|
+
},
|
|
96
|
+
close: () => {
|
|
97
|
+
closed = true;
|
|
98
|
+
consumer = undefined;
|
|
99
|
+
onDead = undefined;
|
|
100
|
+
stop();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
package/dist/panel.js
CHANGED
|
@@ -227,9 +227,10 @@ export function DockerPanel(props) {
|
|
|
227
227
|
const engine = createEngineProbe();
|
|
228
228
|
// The probe only has to be re-read when the poll changes its verdict: an available docker proves the
|
|
229
229
|
// engine answers, and a probe on every poll would stack several four second calls on the same pipe,
|
|
230
|
-
// while never re-reading it would leave a stale stopped engine once docker starts from the tray
|
|
230
|
+
// while never re-reading it would leave a stale stopped engine once docker starts from the tray, which
|
|
231
|
+
// poll.ts covers by keeping the poll fast while the last answer said stopped
|
|
231
232
|
let lastKind = "";
|
|
232
|
-
const polling = createDockerPolling(props.
|
|
233
|
+
const polling = createDockerPolling(props.reload, next => {
|
|
233
234
|
refreshStack();
|
|
234
235
|
if (next.kind === lastKind) return;
|
|
235
236
|
lastKind = next.kind;
|
|
@@ -647,7 +648,7 @@ export function DockerPanel(props) {
|
|
|
647
648
|
}), null);
|
|
648
649
|
_$insert(_el$18, _$createComponent(Show, {
|
|
649
650
|
get when() {
|
|
650
|
-
return _$memo(() => !!pendingStack())() && !
|
|
651
|
+
return _$memo(() => !!(pendingStack() && !engineDown()))() && !stopping();
|
|
651
652
|
},
|
|
652
653
|
get children() {
|
|
653
654
|
var _el$42 = _$createElement("text"),
|
package/dist/poll.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { createEventWatch } from "./events.js";
|
|
2
3
|
import { runDockerPs } from "./docker.js";
|
|
3
4
|
import { runDesktopStatus } from "./runtime.js";
|
|
4
5
|
const INITIAL = {
|
|
@@ -10,6 +11,22 @@ const INITIAL = {
|
|
|
10
11
|
/** docker reports the old state for a moment after start or stop, so an action is polled twice */
|
|
11
12
|
const SETTLE_MS = 1200;
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Events drive the panel now, so this is only the net for the case where docker says nothing at all:
|
|
16
|
+
* a container changed outside anything we would hear about, or the stream is gone and the engine has
|
|
17
|
+
* come back since the last attempt.
|
|
18
|
+
*/
|
|
19
|
+
export const FALLBACK_INTERVAL_MS = 30000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Nothing can change while the engine is down, so a start from the tray is the one thing worth waiting
|
|
23
|
+
* on closely, and the engine probe has no timer of its own to notice it.
|
|
24
|
+
*/
|
|
25
|
+
const ENGINE_DOWN_RETRY_MS = 3000;
|
|
26
|
+
export function fallbackDelayMs(kind) {
|
|
27
|
+
return kind === "stopped" ? ENGINE_DOWN_RETRY_MS : FALLBACK_INTERVAL_MS;
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
/**
|
|
14
31
|
* A Docker Desktop cold start boots a virtual machine, which takes a minute and answers `unknown` while
|
|
15
32
|
* it goes. Polling faster than the container loop is what makes the panel notice it came up at all,
|
|
@@ -39,6 +56,13 @@ export function selectRows(all, pinned) {
|
|
|
39
56
|
const running = all.filter(item => !isPinned(item) && item.state === "running");
|
|
40
57
|
return [...fixed, ...running.slice(0, Math.max(0, MAX_RUNNING - fixed.length))];
|
|
41
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* A stream that has closed is only worth reopening once docker answers again. Reopening while the
|
|
61
|
+
* engine is down would spawn a process that cannot work, every tick, for as long as the engine is off.
|
|
62
|
+
*/
|
|
63
|
+
export function shouldReconnect(kind, streamAlive) {
|
|
64
|
+
return !streamAlive && (kind === "ok" || kind === "empty");
|
|
65
|
+
}
|
|
42
66
|
/**
|
|
43
67
|
* The engine has its own probe because `docker ps` cannot report it: on a stopped engine the ps call
|
|
44
68
|
* fails to connect and says only `unavailable`, which is also what a missing CLI or a locked socket
|
|
@@ -93,17 +117,26 @@ function signature(containers) {
|
|
|
93
117
|
|
|
94
118
|
/** A container in a crash loop changes state on every poll and each remount costs a fresh docker ps */
|
|
95
119
|
const RELOAD_COOLDOWN_MS = 10000;
|
|
96
|
-
export function createDockerPolling(
|
|
120
|
+
export function createDockerPolling(onContentChange, onPoll) {
|
|
97
121
|
const [state, setState] = createSignal(INITIAL);
|
|
98
122
|
let timer;
|
|
99
123
|
let settle;
|
|
100
124
|
let disposed = false;
|
|
101
|
-
let
|
|
102
|
-
let
|
|
125
|
+
let shown = "";
|
|
126
|
+
let mounted = false;
|
|
103
127
|
let reloadedAt = 0;
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `shown` is what the host has been told, not what was last seen. A change that arrives inside the
|
|
131
|
+
* cooldown has to stay owed rather than be written off, because the signature would match on every
|
|
132
|
+
* later poll and nothing would ever deliver it.
|
|
133
|
+
*/
|
|
134
|
+
const apply = next => {
|
|
106
135
|
if (disposed) return;
|
|
136
|
+
if (shouldReconnect(next.kind, streamAlive)) {
|
|
137
|
+
streamAlive = true;
|
|
138
|
+
events.restart();
|
|
139
|
+
}
|
|
107
140
|
const visible = keepContainers(state().containers, next);
|
|
108
141
|
setState({
|
|
109
142
|
...next,
|
|
@@ -112,26 +145,58 @@ export function createDockerPolling(intervalMs, onContentChange, onPoll) {
|
|
|
112
145
|
onPoll?.(next);
|
|
113
146
|
if (next.kind === "unavailable" || next.kind === "stale") return;
|
|
114
147
|
const current = signature(visible);
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
seen = true;
|
|
148
|
+
if (!mounted) {
|
|
149
|
+
mounted = true;
|
|
150
|
+
shown = current;
|
|
119
151
|
return;
|
|
120
152
|
}
|
|
153
|
+
if (current === shown) return;
|
|
121
154
|
if (Date.now() - reloadedAt < RELOAD_COOLDOWN_MS) return;
|
|
155
|
+
shown = current;
|
|
122
156
|
reloadedAt = Date.now();
|
|
123
157
|
onContentChange();
|
|
124
158
|
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One `docker ps` at a time. Events arrive faster than a ps call returns, and without this the older
|
|
162
|
+
* result lands last and overwrites the newer one. A call that arrives while one is in flight asks for
|
|
163
|
+
* one more pass instead of starting a parallel one, the same guard `createEngineProbe` uses.
|
|
164
|
+
*/
|
|
165
|
+
let reading = false;
|
|
166
|
+
let reread = false;
|
|
167
|
+
const refresh = async () => {
|
|
168
|
+
if (reading) {
|
|
169
|
+
reread = true;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
reading = true;
|
|
173
|
+
try {
|
|
174
|
+
do {
|
|
175
|
+
reread = false;
|
|
176
|
+
apply(await runDockerPs());
|
|
177
|
+
} while (reread && !disposed);
|
|
178
|
+
} finally {
|
|
179
|
+
reading = false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const events = createEventWatch();
|
|
183
|
+
let streamAlive = true;
|
|
184
|
+
events.onEvent(() => void refresh());
|
|
185
|
+
events.onDead(() => {
|
|
186
|
+
streamAlive = false;
|
|
187
|
+
});
|
|
125
188
|
const tick = async () => {
|
|
126
189
|
await refresh();
|
|
127
190
|
if (disposed) return;
|
|
128
|
-
timer = setTimeout(tick,
|
|
191
|
+
timer = setTimeout(tick, fallbackDelayMs(state().kind));
|
|
129
192
|
};
|
|
130
193
|
void tick();
|
|
131
194
|
onCleanup(() => {
|
|
132
195
|
disposed = true;
|
|
133
196
|
if (timer) clearTimeout(timer);
|
|
134
197
|
if (settle) clearTimeout(settle);
|
|
198
|
+
// A docker events process outlives the panel otherwise and stays in the task list
|
|
199
|
+
events.close();
|
|
135
200
|
});
|
|
136
201
|
return {
|
|
137
202
|
state,
|
package/dist/tui.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
2
|
import { DockerPanel } from "./panel.js";
|
|
3
|
-
const MIN_INTERVAL = 1000;
|
|
4
|
-
const MAX_INTERVAL = 60000;
|
|
5
3
|
const plugin = {
|
|
6
4
|
id: "docker.panel.cli",
|
|
7
5
|
setup(context) {
|
|
8
|
-
const requested = Number(context.options.intervalMs);
|
|
9
|
-
const intervalMs = Number.isFinite(requested) ? Math.min(Math.max(requested, MIN_INTERVAL), MAX_INTERVAL) : 3000;
|
|
10
6
|
const agentDir = context.location?.directory ?? process.cwd();
|
|
11
7
|
const [pinned, setPinned] = context.storage.store("docker-panel.pinned", {
|
|
12
8
|
initial: {
|
|
@@ -33,7 +29,6 @@ const plugin = {
|
|
|
33
29
|
const claim = {
|
|
34
30
|
append: "sidebar.content",
|
|
35
31
|
render: () => _$createComponent(DockerPanel, {
|
|
36
|
-
intervalMs: intervalMs,
|
|
37
32
|
agentDir: agentDir,
|
|
38
33
|
get pinned() {
|
|
39
34
|
return pinned.names;
|