opencode-docker-panel 0.4.8
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 +45 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/README.ru.md +194 -0
- package/dist/commands.js +77 -0
- package/dist/compose.js +48 -0
- package/dist/docker.js +114 -0
- package/dist/logs.js +73 -0
- package/dist/panel.js +567 -0
- package/dist/poll.js +86 -0
- package/dist/tui.js +55 -0
- package/dist/types.js +1 -0
- package/package.json +76 -0
package/dist/panel.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
4
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
5
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
6
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
7
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
8
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
9
|
+
import { Index, Show, createSignal, onCleanup } from "solid-js";
|
|
10
|
+
import { ACTION_LABEL, availableActions, buildArgs, runDockerArgs } from "./commands.js";
|
|
11
|
+
import { findComposeFile } from "./compose.js";
|
|
12
|
+
import { runDockerLogs } from "./logs.js";
|
|
13
|
+
import { createDockerPolling, selectRows } from "./poll.js";
|
|
14
|
+
const LOG_VIEW_LINES = 15;
|
|
15
|
+
const LOG_REFRESH_MS = 2000;
|
|
16
|
+
|
|
17
|
+
/** The argv a stack needs, in one place so the menu footer and the run cannot drift apart. */
|
|
18
|
+
function stackArgs(target) {
|
|
19
|
+
return ["compose", "-f", target.file, "-p", target.project, "up", "-d"];
|
|
20
|
+
}
|
|
21
|
+
function dotColor(theme, state) {
|
|
22
|
+
if (state === "running") return theme.text.feedback.success.base;
|
|
23
|
+
if (state === "paused" || state === "restarting") return theme.text.feedback.warning.base;
|
|
24
|
+
if (state === "dead") return theme.text.feedback.error.base;
|
|
25
|
+
return theme.text.muted;
|
|
26
|
+
}
|
|
27
|
+
function levelColor(theme, level) {
|
|
28
|
+
if (level === "error") return theme.text.feedback.error.base;
|
|
29
|
+
if (level === "warn") return theme.text.feedback.warning.base;
|
|
30
|
+
return theme.text.muted;
|
|
31
|
+
}
|
|
32
|
+
function detailLabel(container, pinned) {
|
|
33
|
+
const base = container.ports.length > 0 ? container.ports.join(" ") : container.status || container.image;
|
|
34
|
+
return [base, container.composeProject, pinned.includes(container.name) ? "pinned" : ""].filter(Boolean).join(" · ");
|
|
35
|
+
}
|
|
36
|
+
function hasStaleRows(state) {
|
|
37
|
+
return (state.kind === "unavailable" || state.kind === "stale") && state.containers.length > 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fixed viewport is the only way to keep the height predictable: opentui has no scrollable container. */
|
|
41
|
+
function LogsView(props) {
|
|
42
|
+
const lines = () => props.result?.lines ?? [];
|
|
43
|
+
const end = () => Math.max(0, lines().length - props.offset);
|
|
44
|
+
const window = () => lines().slice(Math.max(0, end() - LOG_VIEW_LINES), end());
|
|
45
|
+
return (() => {
|
|
46
|
+
var _el$ = _$createElement("box"),
|
|
47
|
+
_el$2 = _$createElement("text"),
|
|
48
|
+
_el$3 = _$createTextNode(` docker logs --tail 200 -- `),
|
|
49
|
+
_el$4 = _$createTextNode(` `),
|
|
50
|
+
_el$1 = _$createElement("box"),
|
|
51
|
+
_el$10 = _$createElement("text"),
|
|
52
|
+
_el$11 = _$createTextNode(` `),
|
|
53
|
+
_el$12 = _$createTextNode(` lines`),
|
|
54
|
+
_el$13 = _$createTextNode(` · wheel scrolls · esc or click to close`);
|
|
55
|
+
_$insertNode(_el$, _el$2);
|
|
56
|
+
_$insertNode(_el$, _el$1);
|
|
57
|
+
_$insertNode(_el$, _el$10);
|
|
58
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
59
|
+
_$insertNode(_el$2, _el$3);
|
|
60
|
+
_$insertNode(_el$2, _el$4);
|
|
61
|
+
_$insert(_el$2, () => props.name, _el$4);
|
|
62
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
63
|
+
get when() {
|
|
64
|
+
return props.result?.error;
|
|
65
|
+
},
|
|
66
|
+
get children() {
|
|
67
|
+
var _el$5 = _$createElement("text"),
|
|
68
|
+
_el$6 = _$createTextNode(` `);
|
|
69
|
+
_$insertNode(_el$5, _el$6);
|
|
70
|
+
_$insert(_el$5, () => props.result?.error ?? "", null);
|
|
71
|
+
_$effect(_$p => _$setProp(_el$5, "fg", props.theme.text.feedback.error.base, _$p));
|
|
72
|
+
return _el$5;
|
|
73
|
+
}
|
|
74
|
+
}), _el$1);
|
|
75
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
76
|
+
get when() {
|
|
77
|
+
return _$memo(() => !!(props.result && !props.result.error))() && lines().length === 0;
|
|
78
|
+
},
|
|
79
|
+
get children() {
|
|
80
|
+
var _el$7 = _$createElement("text");
|
|
81
|
+
_$insertNode(_el$7, _$createTextNode(` no output`));
|
|
82
|
+
_$effect(_$p => _$setProp(_el$7, "fg", props.theme.text.muted, _$p));
|
|
83
|
+
return _el$7;
|
|
84
|
+
}
|
|
85
|
+
}), _el$1);
|
|
86
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
87
|
+
get when() {
|
|
88
|
+
return !props.result;
|
|
89
|
+
},
|
|
90
|
+
get children() {
|
|
91
|
+
var _el$9 = _$createElement("text");
|
|
92
|
+
_$insertNode(_el$9, _$createTextNode(` reading logs`));
|
|
93
|
+
_$effect(_$p => _$setProp(_el$9, "fg", props.theme.text.muted, _$p));
|
|
94
|
+
return _el$9;
|
|
95
|
+
}
|
|
96
|
+
}), _el$1);
|
|
97
|
+
_$setProp(_el$1, "flexDirection", "column");
|
|
98
|
+
_$setProp(_el$1, "onMouseScroll", event => {
|
|
99
|
+
event.stopPropagation();
|
|
100
|
+
props.onScroll(event);
|
|
101
|
+
});
|
|
102
|
+
_$insert(_el$1, _$createComponent(Index, {
|
|
103
|
+
get each() {
|
|
104
|
+
return window();
|
|
105
|
+
},
|
|
106
|
+
children: line => (() => {
|
|
107
|
+
var _el$14 = _$createElement("text"),
|
|
108
|
+
_el$15 = _$createElement("span"),
|
|
109
|
+
_el$16 = _$createTextNode(` `),
|
|
110
|
+
_el$17 = _$createElement("span");
|
|
111
|
+
_$insertNode(_el$14, _el$15);
|
|
112
|
+
_$insertNode(_el$14, _el$17);
|
|
113
|
+
_$insertNode(_el$15, _el$16);
|
|
114
|
+
_$insert(_el$15, () => line().time.slice(11, 19), _el$16);
|
|
115
|
+
_$insert(_el$17, () => line().text);
|
|
116
|
+
_$effect(_p$ => {
|
|
117
|
+
var _v$3 = props.theme.text.base,
|
|
118
|
+
_v$4 = {
|
|
119
|
+
fg: props.theme.text.muted
|
|
120
|
+
},
|
|
121
|
+
_v$5 = {
|
|
122
|
+
fg: levelColor(props.theme, line().level)
|
|
123
|
+
};
|
|
124
|
+
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$14, "fg", _v$3, _p$.e));
|
|
125
|
+
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$15, "style", _v$4, _p$.t));
|
|
126
|
+
_v$5 !== _p$.a && (_p$.a = _$setProp(_el$17, "style", _v$5, _p$.a));
|
|
127
|
+
return _p$;
|
|
128
|
+
}, {
|
|
129
|
+
e: undefined,
|
|
130
|
+
t: undefined,
|
|
131
|
+
a: undefined
|
|
132
|
+
});
|
|
133
|
+
return _el$14;
|
|
134
|
+
})()
|
|
135
|
+
}));
|
|
136
|
+
_$insertNode(_el$10, _el$11);
|
|
137
|
+
_$insertNode(_el$10, _el$12);
|
|
138
|
+
_$insertNode(_el$10, _el$13);
|
|
139
|
+
_$setProp(_el$10, "onMouseUp", event => {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
props.onClose();
|
|
142
|
+
});
|
|
143
|
+
_$insert(_el$10, () => lines().length, _el$12);
|
|
144
|
+
_$insert(_el$10, (() => {
|
|
145
|
+
var _c$ = _$memo(() => end() < lines().length);
|
|
146
|
+
return () => _c$() ? `, at ${end()}` : "";
|
|
147
|
+
})(), _el$13);
|
|
148
|
+
_$effect(_p$ => {
|
|
149
|
+
var _v$ = props.theme.text.muted,
|
|
150
|
+
_v$2 = props.theme.text.muted;
|
|
151
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
|
|
152
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$10, "fg", _v$2, _p$.t));
|
|
153
|
+
return _p$;
|
|
154
|
+
}, {
|
|
155
|
+
e: undefined,
|
|
156
|
+
t: undefined
|
|
157
|
+
});
|
|
158
|
+
return _el$;
|
|
159
|
+
})();
|
|
160
|
+
}
|
|
161
|
+
export function DockerPanel(props) {
|
|
162
|
+
const [open, setOpen] = createSignal(true);
|
|
163
|
+
const [busy, setBusy] = createSignal(false);
|
|
164
|
+
const [logs, setLogs] = createSignal(null);
|
|
165
|
+
const [offset, setOffset] = createSignal(0);
|
|
166
|
+
const theme = () => props.theme;
|
|
167
|
+
let logTimer;
|
|
168
|
+
const stopLogs = () => {
|
|
169
|
+
if (logTimer) clearInterval(logTimer);
|
|
170
|
+
logTimer = undefined;
|
|
171
|
+
setLogs(null);
|
|
172
|
+
setOffset(0);
|
|
173
|
+
};
|
|
174
|
+
const loadLogs = async name => {
|
|
175
|
+
const result = await runDockerLogs(name);
|
|
176
|
+
if (logs()?.name !== name) return;
|
|
177
|
+
setLogs({
|
|
178
|
+
name,
|
|
179
|
+
result
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const scrollLogs = event => {
|
|
183
|
+
const scroll = event.scroll;
|
|
184
|
+
if (!scroll || scroll.direction !== "up" && scroll.direction !== "down") return;
|
|
185
|
+
const total = logs()?.result?.lines.length ?? 0;
|
|
186
|
+
const max = Math.max(0, total - LOG_VIEW_LINES);
|
|
187
|
+
const step = Math.max(1, scroll.delta);
|
|
188
|
+
setOffset(current => Math.min(max, Math.max(0, current + (scroll.direction === "up" ? step : -step))));
|
|
189
|
+
};
|
|
190
|
+
const openLogs = container => {
|
|
191
|
+
if (logTimer) clearInterval(logTimer);
|
|
192
|
+
setOffset(0);
|
|
193
|
+
setLogs({
|
|
194
|
+
name: container.name,
|
|
195
|
+
result: null
|
|
196
|
+
});
|
|
197
|
+
void loadLogs(container.name);
|
|
198
|
+
logTimer = setInterval(() => void loadLogs(container.name), LOG_REFRESH_MS);
|
|
199
|
+
const view = () => _$createComponent(LogsView, {
|
|
200
|
+
get name() {
|
|
201
|
+
return container.name;
|
|
202
|
+
},
|
|
203
|
+
get result() {
|
|
204
|
+
return logs()?.result ?? null;
|
|
205
|
+
},
|
|
206
|
+
get theme() {
|
|
207
|
+
return props.theme;
|
|
208
|
+
},
|
|
209
|
+
get offset() {
|
|
210
|
+
return offset();
|
|
211
|
+
},
|
|
212
|
+
onScroll: scrollLogs,
|
|
213
|
+
onClose: () => props.ui.dialog.clear()
|
|
214
|
+
});
|
|
215
|
+
props.ui.dialog.show(view, stopLogs);
|
|
216
|
+
props.ui.dialog.set({
|
|
217
|
+
size: "large",
|
|
218
|
+
centered: true
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
onCleanup(() => {
|
|
222
|
+
if (logTimer) clearInterval(logTimer);
|
|
223
|
+
if (logs()) props.ui.dialog.clear();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Read the compose file when the panel mounts and again on every poll, never while the host is
|
|
227
|
+
// assembling the render tree.
|
|
228
|
+
const [stackSignal, setStackSignal] = createSignal(null);
|
|
229
|
+
const [pendingStack, setPendingStack] = createSignal(null);
|
|
230
|
+
const derivePendingStack = stack => {
|
|
231
|
+
setPendingStack(stack && !polling.state().containers.some(item => item.composeProject === stack.project) ? stack : null);
|
|
232
|
+
};
|
|
233
|
+
const refreshStack = () => {
|
|
234
|
+
const stack = findComposeFile(props.agentDir);
|
|
235
|
+
setStackSignal(stack);
|
|
236
|
+
derivePendingStack(stack);
|
|
237
|
+
};
|
|
238
|
+
const polling = createDockerPolling(props.intervalMs, props.reload, refreshStack);
|
|
239
|
+
|
|
240
|
+
/** One entry point for every action, so the busy guard and the refresh cannot be forgotten. */
|
|
241
|
+
const run = async (action, title, args) => {
|
|
242
|
+
if (busy()) return;
|
|
243
|
+
setBusy(true);
|
|
244
|
+
const result = await runDockerArgs(args);
|
|
245
|
+
setBusy(false);
|
|
246
|
+
props.ui.toast.show({
|
|
247
|
+
title,
|
|
248
|
+
message: result.ok ? `${ACTION_LABEL[action]}: ${result.message}` : result.message,
|
|
249
|
+
variant: result.ok ? "success" : "error"
|
|
250
|
+
});
|
|
251
|
+
await polling.refreshAfterAction();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The stack the agent directory declares, but only while none of its containers is on the machine.
|
|
256
|
+
* Computed once per poll instead of inside the markup: a `Show` and its own label both read this,
|
|
257
|
+
* and re-deriving it during render is what the crash traced back to.
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
/** The list of every container, from which picking one opens the same actions as its row does. */
|
|
261
|
+
const pickContainer = async all => {
|
|
262
|
+
const choice = await props.ui.dialog.select({
|
|
263
|
+
title: `All containers (${all.length})`,
|
|
264
|
+
placeholder: "Container",
|
|
265
|
+
options: all.map(container => ({
|
|
266
|
+
title: container.name,
|
|
267
|
+
value: container,
|
|
268
|
+
description: detailLabel(container, props.pinned),
|
|
269
|
+
footer: container.state
|
|
270
|
+
}))
|
|
271
|
+
});
|
|
272
|
+
if (!choice) return;
|
|
273
|
+
// The list is a snapshot: a container can stop while it is open, so re-read it before offering actions.
|
|
274
|
+
const fresh = polling.state().containers.find(item => item.name === choice.name);
|
|
275
|
+
if (fresh) await openMenu(fresh);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The single door to the containers the sidebar does not show. Clicking the header lands on the
|
|
280
|
+
* list itself, so there is no menu to pick through first.
|
|
281
|
+
*/
|
|
282
|
+
const openBrowse = async () => {
|
|
283
|
+
if (busy()) return;
|
|
284
|
+
const all = polling.state().containers;
|
|
285
|
+
if (all.length === 0) {
|
|
286
|
+
props.ui.toast.show({
|
|
287
|
+
title: "Docker",
|
|
288
|
+
message: "no containers",
|
|
289
|
+
variant: "info"
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await pickContainer(all);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A stack has no container to be the subject of the action, and its file carries build, command and
|
|
298
|
+
* entrypoint, so it asks once and shows the exact argv in the prompt.
|
|
299
|
+
*/
|
|
300
|
+
const openStack = async () => {
|
|
301
|
+
if (busy()) return;
|
|
302
|
+
const stack = pendingStack();
|
|
303
|
+
if (!stack) return;
|
|
304
|
+
const confirmed = await props.ui.dialog.confirm({
|
|
305
|
+
title: `Up ${stack.project}?`,
|
|
306
|
+
message: `Starts every service of this compose project from ${stack.file}:\n\n${stackArgs(stack).join(" ")}`,
|
|
307
|
+
label: {
|
|
308
|
+
confirm: "Up stack",
|
|
309
|
+
cancel: "Cancel"
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
if (!confirmed) return;
|
|
313
|
+
await run("up", stack.project, stackArgs(stack));
|
|
314
|
+
};
|
|
315
|
+
const openMenu = async container => {
|
|
316
|
+
if (busy()) return;
|
|
317
|
+
const isPinned = props.pinned.includes(container.name);
|
|
318
|
+
// Read once per menu: compose can build images, so the action must not wait on a second read.
|
|
319
|
+
const stack = stackSignal()?.project === container.composeProject ? stackSignal() : null;
|
|
320
|
+
const options = [...availableActions(container, stack).map(item => ({
|
|
321
|
+
title: ACTION_LABEL[item],
|
|
322
|
+
value: item,
|
|
323
|
+
description: item === "down" ? `stops every container of ${container.composeProject}` : item === "up" ? `starts every service of ${stack?.project} from the agent directory` : container.status,
|
|
324
|
+
footer: buildArgs(item, container, stack).join(" ")
|
|
325
|
+
})), {
|
|
326
|
+
title: "Logs",
|
|
327
|
+
value: "logs",
|
|
328
|
+
description: "read the last 200 log lines in the sidebar",
|
|
329
|
+
footer: `docker logs --tail 200 -- ${container.name}`
|
|
330
|
+
}, {
|
|
331
|
+
title: isPinned ? "Unpin" : "Pin",
|
|
332
|
+
value: "pin",
|
|
333
|
+
description: isPinned ? "put this container back under the row limit" : "keep this container visible whatever the limit",
|
|
334
|
+
footer: "stored by the host, survives a restart"
|
|
335
|
+
}];
|
|
336
|
+
try {
|
|
337
|
+
const choice = await props.ui.dialog.select({
|
|
338
|
+
title: container.name,
|
|
339
|
+
placeholder: "Action",
|
|
340
|
+
options
|
|
341
|
+
});
|
|
342
|
+
if (!choice) return;
|
|
343
|
+
if (choice === "logs") {
|
|
344
|
+
openLogs(container);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (choice === "pin") {
|
|
348
|
+
await props.onTogglePin(container.name);
|
|
349
|
+
props.ui.toast.show({
|
|
350
|
+
title: container.name,
|
|
351
|
+
message: isPinned ? "Unpinned: back under the row limit" : "Pinned: shown whatever the row limit"
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (choice === "down") {
|
|
356
|
+
const project = container.composeProject;
|
|
357
|
+
const confirmed = await props.ui.dialog.confirm({
|
|
358
|
+
title: `Down ${project}?`,
|
|
359
|
+
message: "Removes this project's containers and networks. Volumes are kept. Not reversible from the panel.",
|
|
360
|
+
label: {
|
|
361
|
+
confirm: "Down",
|
|
362
|
+
cancel: "Cancel"
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (!confirmed) return;
|
|
366
|
+
}
|
|
367
|
+
await run(choice, container.name, buildArgs(choice, container, stack));
|
|
368
|
+
} catch (error) {
|
|
369
|
+
props.ui.toast.show({
|
|
370
|
+
title: container.name,
|
|
371
|
+
message: `action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
372
|
+
variant: "error"
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// A click in opentui is a mousedown plus a mouseup, and the host dismisses an overlay from the
|
|
378
|
+
// next mouse event it sees. Every dialog is therefore armed on the release, and only mounted after
|
|
379
|
+
// that release has finished propagating, so the release cannot land on its own backdrop.
|
|
380
|
+
const arm = (open, event) => {
|
|
381
|
+
event.stopPropagation();
|
|
382
|
+
setTimeout(() => void open(), 0);
|
|
383
|
+
};
|
|
384
|
+
const visible = () => selectRows(polling.state().containers, props.pinned);
|
|
385
|
+
const hasRows = () => visible().length > 0;
|
|
386
|
+
const pressHeader = () => {
|
|
387
|
+
if (hasRows()) {
|
|
388
|
+
setOpen(value => !value);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
void openBrowse();
|
|
392
|
+
};
|
|
393
|
+
return (() => {
|
|
394
|
+
var _el$18 = _$createElement("box"),
|
|
395
|
+
_el$19 = _$createElement("box"),
|
|
396
|
+
_el$20 = _$createElement("text"),
|
|
397
|
+
_el$21 = _$createElement("b");
|
|
398
|
+
_$insertNode(_el$18, _el$19);
|
|
399
|
+
_$insertNode(_el$19, _el$20);
|
|
400
|
+
_$setProp(_el$19, "flexDirection", "row");
|
|
401
|
+
_$setProp(_el$19, "gap", 1);
|
|
402
|
+
_$setProp(_el$19, "onMouseUp", event => arm(pressHeader, event));
|
|
403
|
+
_$insertNode(_el$20, _el$21);
|
|
404
|
+
_$insertNode(_el$21, _$createTextNode(`Docker`));
|
|
405
|
+
_$insert(_el$20, _$createComponent(Show, {
|
|
406
|
+
get when() {
|
|
407
|
+
return polling.state().containers.length > 0;
|
|
408
|
+
},
|
|
409
|
+
get children() {
|
|
410
|
+
var _el$23 = _$createElement("span"),
|
|
411
|
+
_el$24 = _$createTextNode(` (`),
|
|
412
|
+
_el$25 = _$createTextNode(`)`);
|
|
413
|
+
_$insertNode(_el$23, _el$24);
|
|
414
|
+
_$insertNode(_el$23, _el$25);
|
|
415
|
+
_$insert(_el$23, () => polling.state().containers.length, _el$25);
|
|
416
|
+
_$effect(_$p => _$setProp(_el$23, "style", {
|
|
417
|
+
fg: theme().text.muted
|
|
418
|
+
}, _$p));
|
|
419
|
+
return _el$23;
|
|
420
|
+
}
|
|
421
|
+
}), null);
|
|
422
|
+
_$insert(_el$20, _$createComponent(Show, {
|
|
423
|
+
get when() {
|
|
424
|
+
return _$memo(() => !!!hasRows())() && polling.state().containers.length > 0;
|
|
425
|
+
},
|
|
426
|
+
get children() {
|
|
427
|
+
var _el$26 = _$createElement("span");
|
|
428
|
+
_$insertNode(_el$26, _$createTextNode(` click for the list`));
|
|
429
|
+
_$effect(_$p => _$setProp(_el$26, "style", {
|
|
430
|
+
fg: theme().text.muted
|
|
431
|
+
}, _$p));
|
|
432
|
+
return _el$26;
|
|
433
|
+
}
|
|
434
|
+
}), null);
|
|
435
|
+
_$insert(_el$19, _$createComponent(Show, {
|
|
436
|
+
get when() {
|
|
437
|
+
return hasStaleRows(polling.state());
|
|
438
|
+
},
|
|
439
|
+
get children() {
|
|
440
|
+
var _el$28 = _$createElement("text");
|
|
441
|
+
_$insertNode(_el$28, _$createTextNode(` stale`));
|
|
442
|
+
_$effect(_$p => _$setProp(_el$28, "fg", theme().text.feedback.warning.base, _$p));
|
|
443
|
+
return _el$28;
|
|
444
|
+
}
|
|
445
|
+
}), null);
|
|
446
|
+
_$insert(_el$19, _$createComponent(Show, {
|
|
447
|
+
get when() {
|
|
448
|
+
return busy();
|
|
449
|
+
},
|
|
450
|
+
get children() {
|
|
451
|
+
var _el$30 = _$createElement("text");
|
|
452
|
+
_$insertNode(_el$30, _$createTextNode(` working`));
|
|
453
|
+
_$effect(_$p => _$setProp(_el$30, "fg", theme().text.muted, _$p));
|
|
454
|
+
return _el$30;
|
|
455
|
+
}
|
|
456
|
+
}), null);
|
|
457
|
+
_$insert(_el$18, _$createComponent(Show, {
|
|
458
|
+
get when() {
|
|
459
|
+
return _$memo(() => polling.state().containers.length === 0)() && polling.state().detail.length > 0;
|
|
460
|
+
},
|
|
461
|
+
get children() {
|
|
462
|
+
var _el$32 = _$createElement("text"),
|
|
463
|
+
_el$33 = _$createTextNode(` `);
|
|
464
|
+
_$insertNode(_el$32, _el$33);
|
|
465
|
+
_$insert(_el$32, () => polling.state().detail, null);
|
|
466
|
+
_$effect(_$p => _$setProp(_el$32, "fg", theme().text.muted, _$p));
|
|
467
|
+
return _el$32;
|
|
468
|
+
}
|
|
469
|
+
}), null);
|
|
470
|
+
_$insert(_el$18, _$createComponent(Show, {
|
|
471
|
+
get when() {
|
|
472
|
+
return _$memo(() => polling.state().containers.length > 0)() && open();
|
|
473
|
+
},
|
|
474
|
+
get children() {
|
|
475
|
+
return [_$createComponent(Index, {
|
|
476
|
+
get each() {
|
|
477
|
+
return visible();
|
|
478
|
+
},
|
|
479
|
+
children: container => (() => {
|
|
480
|
+
var _el$43 = _$createElement("box"),
|
|
481
|
+
_el$44 = _$createElement("text"),
|
|
482
|
+
_el$46 = _$createElement("text"),
|
|
483
|
+
_el$47 = _$createTextNode(` `),
|
|
484
|
+
_el$48 = _$createElement("span");
|
|
485
|
+
_$insertNode(_el$43, _el$44);
|
|
486
|
+
_$insertNode(_el$43, _el$46);
|
|
487
|
+
_$setProp(_el$43, "flexDirection", "row");
|
|
488
|
+
_$setProp(_el$43, "gap", 1);
|
|
489
|
+
_$setProp(_el$43, "onMouseUp", event => arm(() => openMenu(container()), event));
|
|
490
|
+
_$insertNode(_el$44, _$createTextNode(`•`));
|
|
491
|
+
_$setProp(_el$44, "flexShrink", 0);
|
|
492
|
+
_$insertNode(_el$46, _el$47);
|
|
493
|
+
_$insertNode(_el$46, _el$48);
|
|
494
|
+
_$setProp(_el$46, "wrapMode", "word");
|
|
495
|
+
_$insert(_el$46, () => container().name, _el$47);
|
|
496
|
+
_$insert(_el$48, () => detailLabel(container(), props.pinned));
|
|
497
|
+
_$effect(_p$ => {
|
|
498
|
+
var _v$8 = dotColor(theme(), container().state),
|
|
499
|
+
_v$9 = theme().text.base,
|
|
500
|
+
_v$0 = {
|
|
501
|
+
fg: theme().text.muted
|
|
502
|
+
};
|
|
503
|
+
_v$8 !== _p$.e && (_p$.e = _$setProp(_el$44, "fg", _v$8, _p$.e));
|
|
504
|
+
_v$9 !== _p$.t && (_p$.t = _$setProp(_el$46, "fg", _v$9, _p$.t));
|
|
505
|
+
_v$0 !== _p$.a && (_p$.a = _$setProp(_el$48, "style", _v$0, _p$.a));
|
|
506
|
+
return _p$;
|
|
507
|
+
}, {
|
|
508
|
+
e: undefined,
|
|
509
|
+
t: undefined,
|
|
510
|
+
a: undefined
|
|
511
|
+
});
|
|
512
|
+
return _el$43;
|
|
513
|
+
})()
|
|
514
|
+
}), _$createComponent(Show, {
|
|
515
|
+
get when() {
|
|
516
|
+
return _$memo(() => !!hasRows())() && polling.state().containers.length > visible().length;
|
|
517
|
+
},
|
|
518
|
+
get children() {
|
|
519
|
+
var _el$34 = _$createElement("text"),
|
|
520
|
+
_el$35 = _$createTextNode(` `),
|
|
521
|
+
_el$36 = _$createTextNode(` more, click for all`);
|
|
522
|
+
_$insertNode(_el$34, _el$35);
|
|
523
|
+
_$insertNode(_el$34, _el$36);
|
|
524
|
+
_$setProp(_el$34, "onMouseUp", event => arm(openBrowse, event));
|
|
525
|
+
_$insert(_el$34, () => polling.state().containers.length - visible().length, _el$36);
|
|
526
|
+
_$effect(_$p => _$setProp(_el$34, "fg", theme().text.muted, _$p));
|
|
527
|
+
return _el$34;
|
|
528
|
+
}
|
|
529
|
+
})];
|
|
530
|
+
}
|
|
531
|
+
}), null);
|
|
532
|
+
_$insert(_el$18, _$createComponent(Show, {
|
|
533
|
+
get when() {
|
|
534
|
+
return pendingStack();
|
|
535
|
+
},
|
|
536
|
+
get children() {
|
|
537
|
+
var _el$37 = _$createElement("text"),
|
|
538
|
+
_el$38 = _$createElement("b"),
|
|
539
|
+
_el$40 = _$createElement("span"),
|
|
540
|
+
_el$41 = _$createTextNode(` · `),
|
|
541
|
+
_el$42 = _$createTextNode(` is not running here`);
|
|
542
|
+
_$insertNode(_el$37, _el$38);
|
|
543
|
+
_$insertNode(_el$37, _el$40);
|
|
544
|
+
_$setProp(_el$37, "onMouseUp", event => arm(() => void openStack(), event));
|
|
545
|
+
_$insertNode(_el$38, _$createTextNode(`Up stack`));
|
|
546
|
+
_$insertNode(_el$40, _el$41);
|
|
547
|
+
_$insertNode(_el$40, _el$42);
|
|
548
|
+
_$insert(_el$40, () => pendingStack()?.project, _el$42);
|
|
549
|
+
_$effect(_p$ => {
|
|
550
|
+
var _v$6 = theme().text.base,
|
|
551
|
+
_v$7 = {
|
|
552
|
+
fg: theme().text.muted
|
|
553
|
+
};
|
|
554
|
+
_v$6 !== _p$.e && (_p$.e = _$setProp(_el$37, "fg", _v$6, _p$.e));
|
|
555
|
+
_v$7 !== _p$.t && (_p$.t = _$setProp(_el$40, "style", _v$7, _p$.t));
|
|
556
|
+
return _p$;
|
|
557
|
+
}, {
|
|
558
|
+
e: undefined,
|
|
559
|
+
t: undefined
|
|
560
|
+
});
|
|
561
|
+
return _el$37;
|
|
562
|
+
}
|
|
563
|
+
}), null);
|
|
564
|
+
_$effect(_$p => _$setProp(_el$20, "fg", theme().text.base, _$p));
|
|
565
|
+
return _el$18;
|
|
566
|
+
})();
|
|
567
|
+
}
|
package/dist/poll.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { runDockerPs } from "./docker.js";
|
|
3
|
+
const INITIAL = {
|
|
4
|
+
kind: "empty",
|
|
5
|
+
containers: [],
|
|
6
|
+
detail: "loading"
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** docker reports the old state for a moment after start or stop, so an action is polled twice. */
|
|
10
|
+
const SETTLE_MS = 1200;
|
|
11
|
+
export function keepContainers(previous, next) {
|
|
12
|
+
if (next.kind === "unavailable" || next.kind === "stale") return previous;
|
|
13
|
+
return next.containers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The sidebar belongs to what runs right now, so it shows five running containers and nothing else. */
|
|
17
|
+
export const MAX_RUNNING = 5;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A pinned container is shown whatever its state, and spends the running budget when it happens to run,
|
|
21
|
+
* so pinning everything shows everything. Stopped containers get no row: the dialog is their only door.
|
|
22
|
+
*/
|
|
23
|
+
export function selectRows(all, pinned) {
|
|
24
|
+
const isPinned = item => pinned.includes(item.name);
|
|
25
|
+
const fixed = all.filter(isPinned);
|
|
26
|
+
const running = all.filter(item => !isPinned(item) && item.state === "running");
|
|
27
|
+
return [...fixed, ...running.slice(0, Math.max(0, MAX_RUNNING - fixed.length))];
|
|
28
|
+
}
|
|
29
|
+
/** The host does not repaint this slot when only a signal changes, so the panel is remounted on a real change. */
|
|
30
|
+
function signature(containers) {
|
|
31
|
+
return containers.map(item => `${item.name}:${item.state}`).join("|");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A container in a crash loop changes state on every poll, and each remount costs a fresh docker ps. */
|
|
35
|
+
const RELOAD_COOLDOWN_MS = 10000;
|
|
36
|
+
export function createDockerPolling(intervalMs, onContentChange, onPoll) {
|
|
37
|
+
const [state, setState] = createSignal(INITIAL);
|
|
38
|
+
let timer;
|
|
39
|
+
let settle;
|
|
40
|
+
let disposed = false;
|
|
41
|
+
let last = "";
|
|
42
|
+
let seen = false;
|
|
43
|
+
let reloadedAt = 0;
|
|
44
|
+
const refresh = async () => {
|
|
45
|
+
const next = await runDockerPs();
|
|
46
|
+
if (disposed) return;
|
|
47
|
+
const visible = keepContainers(state().containers, next);
|
|
48
|
+
setState({
|
|
49
|
+
...next,
|
|
50
|
+
containers: visible
|
|
51
|
+
});
|
|
52
|
+
onPoll?.();
|
|
53
|
+
if (next.kind === "unavailable" || next.kind === "stale") return;
|
|
54
|
+
const current = signature(visible);
|
|
55
|
+
if (current === last) return;
|
|
56
|
+
last = current;
|
|
57
|
+
if (!seen) {
|
|
58
|
+
seen = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (Date.now() - reloadedAt < RELOAD_COOLDOWN_MS) return;
|
|
62
|
+
reloadedAt = Date.now();
|
|
63
|
+
onContentChange();
|
|
64
|
+
};
|
|
65
|
+
const tick = async () => {
|
|
66
|
+
await refresh();
|
|
67
|
+
if (disposed) return;
|
|
68
|
+
timer = setTimeout(tick, intervalMs);
|
|
69
|
+
};
|
|
70
|
+
void tick();
|
|
71
|
+
onCleanup(() => {
|
|
72
|
+
disposed = true;
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
if (settle) clearTimeout(settle);
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
state,
|
|
78
|
+
refresh,
|
|
79
|
+
refreshAfterAction: async () => {
|
|
80
|
+
await refresh();
|
|
81
|
+
if (disposed) return;
|
|
82
|
+
if (settle) clearTimeout(settle);
|
|
83
|
+
settle = setTimeout(() => void refresh(), SETTLE_MS);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|