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/docker.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const COMMAND = "docker";
|
|
3
|
+
const ARGS = ["ps", "--all", "--format", "{{json .}}"];
|
|
4
|
+
const TIMEOUT_MS = 4000;
|
|
5
|
+
const MAX_BUFFER = 4 * 1024 * 1024;
|
|
6
|
+
const COMPOSE_PROJECT = "com.docker.compose.project";
|
|
7
|
+
const COMPOSE_SERVICE = "com.docker.compose.service";
|
|
8
|
+
export function shortReason(raw) {
|
|
9
|
+
const text = raw.toLowerCase();
|
|
10
|
+
if (text.includes("dockerdesktoplinuxengine") || text.includes("cannot find the file specified")) {
|
|
11
|
+
return "docker desktop not running";
|
|
12
|
+
}
|
|
13
|
+
if (text.includes("docker_engine") || text.includes("is the server running")) return "no connection to docker";
|
|
14
|
+
if (text.includes("access is denied") || text.includes("permission denied")) return "no permission to talk to docker";
|
|
15
|
+
const firstLine = raw.split(/\r?\n/).map(line => line.trim()).find(line => line.length > 0);
|
|
16
|
+
if (!firstLine) return "no connection to docker";
|
|
17
|
+
return firstLine.length > 60 ? `${firstLine.slice(0, 57)}...` : firstLine;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Docker joins labels with commas, and neither a compose project nor a service name may contain one, so a plain split is enough. */
|
|
21
|
+
function composeLabels(raw) {
|
|
22
|
+
let composeProject;
|
|
23
|
+
let composeService;
|
|
24
|
+
for (const pair of raw.split(",")) {
|
|
25
|
+
const eq = pair.indexOf("=");
|
|
26
|
+
if (eq <= 0) continue;
|
|
27
|
+
const key = pair.slice(0, eq).trim();
|
|
28
|
+
if (key === COMPOSE_PROJECT) composeProject = pair.slice(eq + 1).trim();else if (key === COMPOSE_SERVICE) composeService = pair.slice(eq + 1).trim();
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
composeProject,
|
|
32
|
+
composeService
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** docker reports CreatedAt either as a unix timestamp in seconds or as a date with a trailing zone name, which Date.parse rejects. */
|
|
37
|
+
function parseCreatedAt(raw) {
|
|
38
|
+
const text = String(raw ?? "").trim();
|
|
39
|
+
if (!text) return 0;
|
|
40
|
+
if (/^\d+$/.test(text)) return Number(text) * 1000;
|
|
41
|
+
const parsed = Date.parse(text.replace(/\s+[A-Za-z]{2,5}$/, ""));
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
43
|
+
}
|
|
44
|
+
function toContainer(raw) {
|
|
45
|
+
const name = String(raw.Names ?? "").replace(/^\//, "").trim();
|
|
46
|
+
if (!name) return null;
|
|
47
|
+
const compose = composeLabels(String(raw.Labels ?? ""));
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
image: String(raw.Image ?? ""),
|
|
51
|
+
state: String(raw.State ?? "unknown"),
|
|
52
|
+
status: String(raw.Status ?? ""),
|
|
53
|
+
ports: String(raw.Ports ?? "").split(",").map(item => item.trim()).filter(Boolean),
|
|
54
|
+
createdAt: parseCreatedAt(raw.CreatedAt),
|
|
55
|
+
...compose
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function parseDockerPs(stdout) {
|
|
59
|
+
const containers = [];
|
|
60
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (!trimmed) continue;
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
70
|
+
const container = toContainer(raw);
|
|
71
|
+
if (container) containers.push(container);
|
|
72
|
+
}
|
|
73
|
+
containers.sort((a, b) => {
|
|
74
|
+
const aRunning = a.state === "running";
|
|
75
|
+
const bRunning = b.state === "running";
|
|
76
|
+
if (aRunning !== bRunning) return aRunning ? -1 : 1;
|
|
77
|
+
if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
|
|
78
|
+
return a.name.localeCompare(b.name);
|
|
79
|
+
});
|
|
80
|
+
return containers;
|
|
81
|
+
}
|
|
82
|
+
export function runDockerPs(timeoutMs = TIMEOUT_MS) {
|
|
83
|
+
return new Promise(resolve => {
|
|
84
|
+
execFile(COMMAND, ARGS, {
|
|
85
|
+
timeout: timeoutMs,
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
maxBuffer: MAX_BUFFER
|
|
88
|
+
}, (error, stdout, stderr) => {
|
|
89
|
+
if (!error) {
|
|
90
|
+
const containers = parseDockerPs(String(stdout));
|
|
91
|
+
resolve({
|
|
92
|
+
kind: containers.length > 0 ? "ok" : "empty",
|
|
93
|
+
containers,
|
|
94
|
+
detail: containers.length > 0 ? "" : "no containers"
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const failure = error;
|
|
99
|
+
if (failure.code === "ETIMEDOUT" || failure.killed === true) {
|
|
100
|
+
resolve({
|
|
101
|
+
kind: "stale",
|
|
102
|
+
containers: [],
|
|
103
|
+
detail: "docker did not respond"
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
resolve({
|
|
108
|
+
kind: "unavailable",
|
|
109
|
+
containers: [],
|
|
110
|
+
detail: failure.code === "ENOENT" ? "docker not installed" : shortReason(String(stderr || error.message))
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
package/dist/logs.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const COMMAND = "docker";
|
|
3
|
+
const TAIL = 200;
|
|
4
|
+
const TIMEOUT_MS = 4000;
|
|
5
|
+
const MAX_BUFFER = 4 * 1024 * 1024;
|
|
6
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
7
|
+
|
|
8
|
+
/** One source for both timestamp shapes, so the two patterns cannot drift apart. */
|
|
9
|
+
const STAMP = String.raw`\d{4}-\d{2}-\d{2}T\S+`;
|
|
10
|
+
const TIMESTAMP_ONLY = new RegExp(`^${STAMP}$`);
|
|
11
|
+
const TIMESTAMPED = new RegExp(`^(${STAMP})\\s+(.*)$`);
|
|
12
|
+
/** The terminal would run ANSI sequences from a log as control codes, and a lone CR is a line terminator, not an overwrite. */
|
|
13
|
+
export function sanitize(raw) {
|
|
14
|
+
return raw.replace(ANSI, "").split(/\r\n|\r|\n/).map(line => line.trimEnd()).filter(line => line.length > 0);
|
|
15
|
+
}
|
|
16
|
+
function level(text) {
|
|
17
|
+
const lower = text.toLowerCase();
|
|
18
|
+
if (/\b(error|fatal|panic|exception|traceback|failed)\b/.test(lower)) return "error";
|
|
19
|
+
if (/\b(warn|warning|deprecated)\b/.test(lower)) return "warn";
|
|
20
|
+
return "info";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** docker prefixes every line with an RFC3339 timestamp when asked, so a log line splits into a time and the rest. */
|
|
24
|
+
export function parseLogLines(raw) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
for (const line of sanitize(raw)) {
|
|
27
|
+
if (TIMESTAMP_ONLY.test(line)) continue;
|
|
28
|
+
const match = TIMESTAMPED.exec(line);
|
|
29
|
+
if (!match) {
|
|
30
|
+
lines.push({
|
|
31
|
+
time: "",
|
|
32
|
+
level: level(line),
|
|
33
|
+
text: line
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const text = match[2] ?? "";
|
|
38
|
+
if (text.trim().length > 0) lines.push({
|
|
39
|
+
time: match[1] ?? "",
|
|
40
|
+
level: level(text),
|
|
41
|
+
text
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** docker interleaves the container's two streams, so the real order only survives in the timestamps. */
|
|
48
|
+
export function mergeLogLines(parts) {
|
|
49
|
+
const lines = parts.flatMap(part => parseLogLines(part));
|
|
50
|
+
return lines.sort((a, b) => (a.time || "").localeCompare(b.time || "")).slice(-TAIL);
|
|
51
|
+
}
|
|
52
|
+
export function runDockerLogs(name, timeoutMs = TIMEOUT_MS) {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
execFile(COMMAND, ["logs", "--timestamps", "--tail", String(TAIL), "--", name], {
|
|
55
|
+
timeout: timeoutMs,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
maxBuffer: MAX_BUFFER
|
|
58
|
+
}, (error, stdout, stderr) => {
|
|
59
|
+
if (!error) {
|
|
60
|
+
resolve({
|
|
61
|
+
lines: mergeLogLines([String(stdout), String(stderr)])
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const killed = error.killed === true;
|
|
66
|
+
const reason = sanitize(String(stderr))[0] || (killed ? "docker did not respond" : error.message);
|
|
67
|
+
resolve({
|
|
68
|
+
lines: [],
|
|
69
|
+
error: reason || "docker logs failed"
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|