@paretools/docker 0.8.5 → 0.10.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/README.md +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/docker-runner.d.ts +1 -1
- package/dist/lib/docker-runner.d.ts.map +1 -1
- package/dist/lib/docker-runner.js +2 -2
- package/dist/lib/docker-runner.js.map +1 -1
- package/dist/lib/formatters.d.ts +62 -21
- package/dist/lib/formatters.d.ts.map +1 -1
- package/dist/lib/formatters.js +369 -52
- package/dist/lib/formatters.js.map +1 -1
- package/dist/lib/parsers.d.ts +50 -23
- package/dist/lib/parsers.d.ts.map +1 -1
- package/dist/lib/parsers.js +852 -129
- package/dist/lib/parsers.js.map +1 -1
- package/dist/schemas/index.d.ts +267 -9
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +186 -7
- package/dist/schemas/index.js.map +1 -1
- package/dist/tools/build.d.ts.map +1 -1
- package/dist/tools/build.js +122 -21
- package/dist/tools/build.js.map +1 -1
- package/dist/tools/compose-build.d.ts.map +1 -1
- package/dist/tools/compose-build.js +67 -8
- package/dist/tools/compose-build.js.map +1 -1
- package/dist/tools/compose-down.d.ts.map +1 -1
- package/dist/tools/compose-down.js +39 -7
- package/dist/tools/compose-down.js.map +1 -1
- package/dist/tools/compose-logs.d.ts.map +1 -1
- package/dist/tools/compose-logs.js +52 -7
- package/dist/tools/compose-logs.js.map +1 -1
- package/dist/tools/compose-ps.d.ts.map +1 -1
- package/dist/tools/compose-ps.js +65 -8
- package/dist/tools/compose-ps.js.map +1 -1
- package/dist/tools/compose-up.d.ts.map +1 -1
- package/dist/tools/compose-up.js +83 -6
- package/dist/tools/compose-up.js.map +1 -1
- package/dist/tools/exec.d.ts.map +1 -1
- package/dist/tools/exec.js +66 -17
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/images.d.ts.map +1 -1
- package/dist/tools/images.js +33 -11
- package/dist/tools/images.js.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/inspect.js +43 -16
- package/dist/tools/inspect.js.map +1 -1
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +34 -11
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/network-ls.d.ts.map +1 -1
- package/dist/tools/network-ls.js +23 -10
- package/dist/tools/network-ls.js.map +1 -1
- package/dist/tools/ps.d.ts.map +1 -1
- package/dist/tools/ps.js +21 -6
- package/dist/tools/ps.js.map +1 -1
- package/dist/tools/pull.d.ts.map +1 -1
- package/dist/tools/pull.js +16 -14
- package/dist/tools/pull.js.map +1 -1
- package/dist/tools/run.d.ts.map +1 -1
- package/dist/tools/run.js +107 -10
- package/dist/tools/run.js.map +1 -1
- package/dist/tools/stats.d.ts.map +1 -1
- package/dist/tools/stats.js +21 -6
- package/dist/tools/stats.js.map +1 -1
- package/dist/tools/volume-ls.d.ts.map +1 -1
- package/dist/tools/volume-ls.js +23 -10
- package/dist/tools/volume-ls.js.map +1 -1
- package/package.json +2 -2
package/dist/lib/parsers.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
// ── #117/#118: docker ps with labels and networks ────────────────────
|
|
1
2
|
/** Parses `docker ps --format json` output into structured container data with ports and state. */
|
|
2
3
|
export function parsePsJson(stdout) {
|
|
3
4
|
// docker ps --format json returns one JSON object per line
|
|
4
5
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
5
6
|
const containers = lines.map((line) => {
|
|
6
7
|
const c = JSON.parse(line);
|
|
8
|
+
// #117: Parse labels from JSON output
|
|
9
|
+
const labels = parseLabelsField(c.Labels);
|
|
10
|
+
// #118: Parse networks from JSON output
|
|
11
|
+
const networks = parseNetworksField(c.Networks);
|
|
7
12
|
return {
|
|
8
|
-
id:
|
|
13
|
+
id: c.ID ?? c.Id ?? "",
|
|
9
14
|
name: c.Names ?? c.Name ?? "",
|
|
10
15
|
image: c.Image ?? "",
|
|
11
16
|
status: c.Status ?? "",
|
|
12
17
|
state: (c.State ?? "created").toLowerCase(),
|
|
13
18
|
ports: parsePorts(c.Ports ?? ""),
|
|
14
19
|
created: c.RunningFor ?? c.CreatedAt ?? "",
|
|
20
|
+
...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
|
|
21
|
+
...(networks && networks.length > 0 ? { networks } : {}),
|
|
15
22
|
};
|
|
16
23
|
});
|
|
17
24
|
const running = containers.filter((c) => c.state === "running").length;
|
|
@@ -22,6 +29,45 @@ export function parsePsJson(stdout) {
|
|
|
22
29
|
stopped: containers.length - running,
|
|
23
30
|
};
|
|
24
31
|
}
|
|
32
|
+
/** #117: Parses a labels string or object from Docker JSON output into Record<string, string>. */
|
|
33
|
+
function parseLabelsField(labels) {
|
|
34
|
+
if (!labels)
|
|
35
|
+
return undefined;
|
|
36
|
+
// If labels is already an object
|
|
37
|
+
if (typeof labels === "object" && !Array.isArray(labels)) {
|
|
38
|
+
return labels;
|
|
39
|
+
}
|
|
40
|
+
// If labels is a comma-separated string: "key1=val1,key2=val2"
|
|
41
|
+
if (typeof labels === "string" && labels.trim()) {
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const pair of labels.split(",")) {
|
|
44
|
+
const eqIdx = pair.indexOf("=");
|
|
45
|
+
if (eqIdx > 0) {
|
|
46
|
+
result[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/** #118: Parses a networks field from Docker JSON output into string[]. */
|
|
54
|
+
function parseNetworksField(networks) {
|
|
55
|
+
if (!networks)
|
|
56
|
+
return undefined;
|
|
57
|
+
// If networks is already an array
|
|
58
|
+
if (Array.isArray(networks)) {
|
|
59
|
+
return networks.length > 0 ? networks : undefined;
|
|
60
|
+
}
|
|
61
|
+
// If networks is a comma-separated string: "bridge,mynet"
|
|
62
|
+
if (typeof networks === "string" && networks.trim()) {
|
|
63
|
+
const parts = networks
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((n) => n.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
return parts.length > 0 ? parts : undefined;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
25
71
|
function parsePorts(portsStr) {
|
|
26
72
|
if (!portsStr)
|
|
27
73
|
return [];
|
|
@@ -35,7 +81,8 @@ function parsePorts(portsStr) {
|
|
|
35
81
|
const protocol = (protoMatch?.[1] ?? "tcp");
|
|
36
82
|
const arrowParts = p.split("->");
|
|
37
83
|
if (arrowParts.length === 2) {
|
|
38
|
-
const
|
|
84
|
+
const hostPortMatch = arrowParts[0].match(/:(\d+)$/);
|
|
85
|
+
const hostPort = hostPortMatch?.[1] ?? arrowParts[0].split(":").pop();
|
|
39
86
|
const containerPort = arrowParts[1].replace(/\/(tcp|udp)/, "");
|
|
40
87
|
return {
|
|
41
88
|
host: parseInt(hostPort ?? "0", 10),
|
|
@@ -47,89 +94,293 @@ function parsePorts(portsStr) {
|
|
|
47
94
|
return { container: port, protocol };
|
|
48
95
|
});
|
|
49
96
|
}
|
|
97
|
+
function parseBuildCache(stdout, stderr) {
|
|
98
|
+
const combined = `${stdout}\n${stderr}`;
|
|
99
|
+
const lines = combined.split("\n");
|
|
100
|
+
const cacheByStep = [];
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const stepMatch = line.match(/^#\d+\s+\[([^\]]+)\]\s*(.*)$/);
|
|
104
|
+
if (!stepMatch)
|
|
105
|
+
continue;
|
|
106
|
+
const step = stepMatch[1].trim();
|
|
107
|
+
if (step.toLowerCase() === "internal")
|
|
108
|
+
continue;
|
|
109
|
+
if (seen.has(step))
|
|
110
|
+
continue;
|
|
111
|
+
const tail = stepMatch[2]?.toUpperCase() ?? "";
|
|
112
|
+
const cached = tail.includes("CACHED");
|
|
113
|
+
cacheByStep.push({ step, cached });
|
|
114
|
+
seen.add(step);
|
|
115
|
+
}
|
|
116
|
+
const cacheHits = cacheByStep.filter((s) => s.cached).length;
|
|
117
|
+
const cacheMisses = cacheByStep.filter((s) => !s.cached).length;
|
|
118
|
+
return { cacheHits, cacheMisses, cacheByStep };
|
|
119
|
+
}
|
|
120
|
+
/** #97: Parses build errors from output, extracting line numbers and Dockerfile context. */
|
|
121
|
+
function parseBuildErrors(stdout, stderr) {
|
|
122
|
+
const combined = stdout + "\n" + stderr;
|
|
123
|
+
const errors = [];
|
|
124
|
+
// Match "Dockerfile:N" or "dockerfile:N" patterns in error lines
|
|
125
|
+
const lines = combined.split("\n");
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed)
|
|
129
|
+
continue;
|
|
130
|
+
// Match error lines that reference Dockerfile with line numbers
|
|
131
|
+
// e.g., "Dockerfile:5 error: ..." or "ERROR: failed to solve: Dockerfile:10: ..."
|
|
132
|
+
const dockerfileLineMatch = trimmed.match(/(?:(?:([^\s:]+\.?[Dd]ockerfile[^\s:]*)|Dockerfile):(\d+))[:\s]+(.+)/);
|
|
133
|
+
if (dockerfileLineMatch) {
|
|
134
|
+
errors.push({
|
|
135
|
+
message: dockerfileLineMatch[3].trim(),
|
|
136
|
+
line: parseInt(dockerfileLineMatch[2], 10),
|
|
137
|
+
dockerfile: dockerfileLineMatch[1] || "Dockerfile",
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Match "line N" patterns in error messages
|
|
142
|
+
const lineNumMatch = trimmed.match(/(?:error|ERROR|Error).*?(?:line\s+(\d+)).*$/i);
|
|
143
|
+
if (lineNumMatch && trimmed.match(/error|ERROR|Error/) && !trimmed.match(/^#\d+/)) {
|
|
144
|
+
errors.push({
|
|
145
|
+
message: trimmed,
|
|
146
|
+
line: parseInt(lineNumMatch[1], 10),
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// Match generic error lines (no line number)
|
|
151
|
+
if (trimmed.match(/error|ERROR|Error/) && !trimmed.match(/^#\d+/)) {
|
|
152
|
+
errors.push({ message: trimmed });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return errors;
|
|
156
|
+
}
|
|
50
157
|
/** Parses `docker build` output into structured results with success status, image ID, and errors. */
|
|
51
158
|
export function parseBuildOutput(stdout, stderr, exitCode, duration) {
|
|
52
|
-
const errors = [];
|
|
53
159
|
const imageIdMatch = stdout.match(/writing image sha256:([a-f0-9]+)/i) ||
|
|
54
160
|
stdout.match(/Successfully built ([a-f0-9]+)/) ||
|
|
55
161
|
stderr.match(/writing image sha256:([a-f0-9]+)/i);
|
|
162
|
+
// #97: Parse structured errors with line numbers
|
|
163
|
+
let errors;
|
|
164
|
+
let errorCount;
|
|
56
165
|
if (exitCode !== 0) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
errors.
|
|
166
|
+
errors = parseBuildErrors(stdout, stderr);
|
|
167
|
+
errorCount = errors.length;
|
|
168
|
+
// Fallback: if no structured errors found, extract raw error lines
|
|
169
|
+
if (errors.length === 0) {
|
|
170
|
+
const errorLines = (stdout + "\n" + stderr)
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter((l) => l.match(/error|ERROR|Error/) && !l.match(/^#\d+/));
|
|
173
|
+
errors = errorLines
|
|
174
|
+
.map((l) => l.trim())
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.map((msg) => ({ message: msg }));
|
|
177
|
+
errorCount = errors.length;
|
|
178
|
+
}
|
|
61
179
|
}
|
|
62
180
|
const stepMatch = stdout.match(/#(\d+) /g);
|
|
63
181
|
const steps = stepMatch ? new Set(stepMatch).size : undefined;
|
|
182
|
+
const cache = parseBuildCache(stdout, stderr);
|
|
64
183
|
return {
|
|
65
184
|
success: exitCode === 0,
|
|
66
185
|
imageId: imageIdMatch?.[1]?.slice(0, 12),
|
|
67
186
|
duration,
|
|
68
187
|
...(steps ? { steps } : {}),
|
|
69
|
-
|
|
188
|
+
...(cache.cacheByStep.length > 0
|
|
189
|
+
? {
|
|
190
|
+
cacheHits: cache.cacheHits,
|
|
191
|
+
cacheMisses: cache.cacheMisses,
|
|
192
|
+
cacheByStep: cache.cacheByStep,
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
195
|
+
...(errors && errors.length > 0 ? { errors, errorCount } : {}),
|
|
70
196
|
};
|
|
71
197
|
}
|
|
72
|
-
|
|
73
|
-
|
|
198
|
+
// ── #113: Separate stdout/stderr in logs ─────────────────────────────
|
|
199
|
+
/** Parses `docker logs` output into structured data with container name and log lines.
|
|
200
|
+
* #113: Supports separate stdout/stderr streams when provided.
|
|
201
|
+
* Caps output to `limit` lines when provided. */
|
|
202
|
+
export function parseLogsOutput(stdout, container, limit, stderr) {
|
|
74
203
|
const allLines = stdout.split("\n").filter(Boolean);
|
|
75
204
|
const totalLines = allLines.length;
|
|
76
205
|
const isTruncated = limit != null && totalLines > limit;
|
|
77
206
|
const lines = isTruncated ? allLines.slice(0, limit) : allLines;
|
|
207
|
+
const tsRe = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/;
|
|
208
|
+
const entries = lines.map((line) => {
|
|
209
|
+
const tsMatch = line.match(tsRe);
|
|
210
|
+
if (tsMatch) {
|
|
211
|
+
return { timestamp: tsMatch[1], message: tsMatch[2] };
|
|
212
|
+
}
|
|
213
|
+
return { message: line };
|
|
214
|
+
});
|
|
215
|
+
// #113: Separate stdout and stderr lines
|
|
216
|
+
const stdoutLines = stdout ? stdout.split("\n").filter(Boolean) : undefined;
|
|
217
|
+
const stderrLines = stderr && stderr.trim() ? stderr.split("\n").filter(Boolean) : undefined;
|
|
78
218
|
return {
|
|
79
219
|
container,
|
|
80
220
|
lines,
|
|
221
|
+
...(entries.length > 0 ? { entries } : {}),
|
|
81
222
|
total: lines.length,
|
|
82
223
|
...(isTruncated ? { isTruncated: true, totalLines } : {}),
|
|
224
|
+
...(stdoutLines && stdoutLines.length > 0 ? { stdoutLines } : {}),
|
|
225
|
+
...(stderrLines && stderrLines.length > 0 ? { stderrLines } : {}),
|
|
83
226
|
};
|
|
84
227
|
}
|
|
85
|
-
|
|
228
|
+
// ── #110: Parse CreatedAt as ISO timestamp for images ────────────────
|
|
229
|
+
/** Parses `docker images --format json` output into structured image data with repository, tag, size, and digest. */
|
|
86
230
|
export function parseImagesJson(stdout) {
|
|
87
231
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
88
232
|
const images = lines.map((line) => {
|
|
89
233
|
const img = JSON.parse(line);
|
|
234
|
+
// #110: Parse CreatedAt as ISO timestamp
|
|
235
|
+
const createdAt = img.CreatedAt ? normalizeTimestamp(img.CreatedAt) : undefined;
|
|
236
|
+
const labels = parseLabelsField(img.Labels);
|
|
237
|
+
const size = img.Size ?? "";
|
|
238
|
+
const sizeBytes = size ? parseSizeToBytes(size) : undefined;
|
|
90
239
|
return {
|
|
91
240
|
id: (img.ID ?? "").slice(0, 12),
|
|
92
241
|
repository: img.Repository ?? "",
|
|
93
242
|
tag: img.Tag ?? "",
|
|
94
|
-
size
|
|
243
|
+
size,
|
|
244
|
+
...(sizeBytes != null ? { sizeBytes } : {}),
|
|
245
|
+
...(img.Digest && img.Digest !== "<none>" ? { digest: img.Digest } : {}),
|
|
95
246
|
created: img.CreatedSince ?? img.CreatedAt ?? "",
|
|
247
|
+
...(createdAt ? { createdAt } : {}),
|
|
248
|
+
...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
|
|
96
249
|
};
|
|
97
250
|
});
|
|
98
251
|
return { images, total: images.length };
|
|
99
252
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
253
|
+
/** #110: Normalize a Docker timestamp to ISO 8601 format. */
|
|
254
|
+
function normalizeTimestamp(ts) {
|
|
255
|
+
if (!ts)
|
|
256
|
+
return undefined;
|
|
257
|
+
// Docker CreatedAt: "2024-01-15 10:30:00 +0000 UTC" or ISO format
|
|
258
|
+
// Try to convert to ISO
|
|
259
|
+
const isoMatch = ts.match(/^\d{4}-\d{2}-\d{2}T/);
|
|
260
|
+
if (isoMatch)
|
|
261
|
+
return ts; // Already ISO
|
|
262
|
+
// Convert "2024-01-15 10:30:00 +0000 UTC" format
|
|
263
|
+
const dockerMatch = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*([+-]\d{4})?\s*\w*$/);
|
|
264
|
+
if (dockerMatch) {
|
|
265
|
+
const date = dockerMatch[1];
|
|
266
|
+
const time = dockerMatch[2];
|
|
267
|
+
const offset = dockerMatch[3] || "+0000";
|
|
268
|
+
const offsetFormatted = `${offset.slice(0, 3)}:${offset.slice(3)}`;
|
|
269
|
+
return `${date}T${time}${offsetFormatted}`;
|
|
270
|
+
}
|
|
271
|
+
return ts;
|
|
272
|
+
}
|
|
273
|
+
// ── #121/#122: Run with structured error and stdout/stderr ───────────
|
|
274
|
+
/** Parses `docker run` output into structured data with container ID and image info.
|
|
275
|
+
* #121: Supports structured error output with exitCode, stderr, errorCategory.
|
|
276
|
+
* #122: Captures stdout/stderr for non-detached runs. */
|
|
277
|
+
export function parseRunOutput(stdout, image, detached, name, exitCode, stderr) {
|
|
102
278
|
// When detached, docker run outputs the full container ID
|
|
103
279
|
const containerId = stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
104
|
-
|
|
280
|
+
const result = {
|
|
105
281
|
containerId: containerId.slice(0, 12),
|
|
106
282
|
image,
|
|
107
283
|
detached,
|
|
108
284
|
...(name ? { name } : {}),
|
|
109
285
|
};
|
|
286
|
+
// #121: Include exitCode and error category for failed runs
|
|
287
|
+
if (exitCode != null && exitCode !== 0) {
|
|
288
|
+
result.exitCode = exitCode;
|
|
289
|
+
if (stderr)
|
|
290
|
+
result.stderr = stderr;
|
|
291
|
+
result.errorCategory = categorizeRunError(stderr ?? "");
|
|
292
|
+
}
|
|
293
|
+
// #122: Capture stdout/stderr for non-detached runs
|
|
294
|
+
if (!detached) {
|
|
295
|
+
result.exitCode = exitCode ?? 0;
|
|
296
|
+
if (stdout.trim())
|
|
297
|
+
result.stdout = stdout;
|
|
298
|
+
if (stderr && stderr.trim())
|
|
299
|
+
result.stderr = stderr;
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
110
302
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
303
|
+
/** #121: Categorize a docker run error into a known category. */
|
|
304
|
+
function categorizeRunError(stderr) {
|
|
305
|
+
const lower = stderr.toLowerCase();
|
|
306
|
+
if (lower.includes("not found") ||
|
|
307
|
+
lower.includes("manifest unknown") ||
|
|
308
|
+
lower.includes("no such image") ||
|
|
309
|
+
lower.includes("repository does not exist")) {
|
|
310
|
+
return "image-not-found";
|
|
311
|
+
}
|
|
312
|
+
if (lower.includes("port is already allocated") || lower.includes("address already in use")) {
|
|
313
|
+
return "port-conflict";
|
|
314
|
+
}
|
|
315
|
+
if (lower.includes("permission denied") || lower.includes("access denied")) {
|
|
316
|
+
return "permission-denied";
|
|
317
|
+
}
|
|
318
|
+
if (lower.includes("cannot connect to the docker daemon") ||
|
|
319
|
+
lower.includes("is the docker daemon running")) {
|
|
320
|
+
return "daemon-error";
|
|
321
|
+
}
|
|
322
|
+
return "unknown";
|
|
323
|
+
}
|
|
324
|
+
// ── #108: Exec with output truncation ────────────────────────────────
|
|
325
|
+
/** Parses `docker exec` output into structured data with exit code, stdout, stderr, success, and duration.
|
|
326
|
+
* #108: Supports output truncation with `limit` parameter. */
|
|
327
|
+
export function parseExecOutput(stdout, stderr, exitCode, duration, limit, opts) {
|
|
328
|
+
let truncatedStdout = stdout;
|
|
329
|
+
let truncatedStderr = stderr;
|
|
330
|
+
let isTruncated = false;
|
|
331
|
+
// #108: Truncate output if limit is specified (in bytes/characters)
|
|
332
|
+
if (limit != null && limit > 0) {
|
|
333
|
+
if (stdout.length > limit) {
|
|
334
|
+
truncatedStdout = stdout.slice(0, limit);
|
|
335
|
+
isTruncated = true;
|
|
336
|
+
}
|
|
337
|
+
if (stderr.length > limit) {
|
|
338
|
+
truncatedStderr = stderr.slice(0, limit);
|
|
339
|
+
isTruncated = true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let parsedJson;
|
|
343
|
+
let parseJsonError;
|
|
344
|
+
if (opts?.parseJson) {
|
|
345
|
+
try {
|
|
346
|
+
parsedJson = JSON.parse(truncatedStdout.trim());
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
parseJsonError = err instanceof Error ? err.message : "Invalid JSON output";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
113
352
|
return {
|
|
114
353
|
exitCode,
|
|
115
|
-
stdout,
|
|
116
|
-
stderr,
|
|
354
|
+
stdout: truncatedStdout,
|
|
355
|
+
stderr: truncatedStderr,
|
|
117
356
|
success: exitCode === 0,
|
|
357
|
+
...(duration != null ? { duration } : {}),
|
|
358
|
+
...(isTruncated ? { isTruncated: true } : {}),
|
|
359
|
+
...(opts?.timedOut ? { timedOut: true } : {}),
|
|
360
|
+
...(opts?.parseJson && parsedJson !== undefined ? { json: parsedJson } : {}),
|
|
361
|
+
...(opts?.parseJson && parseJsonError ? { parseJsonError } : {}),
|
|
118
362
|
};
|
|
119
363
|
}
|
|
120
|
-
|
|
364
|
+
// ── #107: Compose up per-service state ───────────────────────────────
|
|
365
|
+
/** Parses `docker compose up` output into structured data with service names and started count.
|
|
366
|
+
* #107: Enriches output with per-service state details. */
|
|
121
367
|
export function parseComposeUpOutput(stdout, stderr, exitCode) {
|
|
122
|
-
// Compose v2 outputs to stderr; service names appear in lines like:
|
|
123
|
-
// ✔ Container myapp-web-1 Started
|
|
124
|
-
// ✔ Container myapp-db-1 Started
|
|
125
|
-
// Or: Container myapp-web-1 Creating / Created / Starting / Started
|
|
126
368
|
const combined = stdout + "\n" + stderr;
|
|
127
369
|
const serviceSet = new Set();
|
|
128
|
-
|
|
129
|
-
const
|
|
370
|
+
const serviceStates = [];
|
|
371
|
+
const seenStates = new Set(); // Deduplicate "name:action" pairs
|
|
372
|
+
const networksCreated = (combined.match(/Network\s+\S+\s+Created/g) ?? []).length;
|
|
373
|
+
const volumesCreated = (combined.match(/Volume\s+\S+\s+Created/g) ?? []).length;
|
|
374
|
+
// Match "Container <name> <Action>" patterns
|
|
375
|
+
const containerPattern = /Container\s+(\S+)\s+(Started|Running|Created|Stopped|Removed|Healthy|Starting|Waiting)/g;
|
|
130
376
|
let match;
|
|
131
|
-
while ((match =
|
|
377
|
+
while ((match = containerPattern.exec(combined)) !== null) {
|
|
132
378
|
serviceSet.add(match[1]);
|
|
379
|
+
const key = `${match[1]}:${match[2]}`;
|
|
380
|
+
if (!seenStates.has(key)) {
|
|
381
|
+
seenStates.add(key);
|
|
382
|
+
serviceStates.push({ name: match[1], action: match[2] });
|
|
383
|
+
}
|
|
133
384
|
}
|
|
134
385
|
// Also match "service "<name>" started" pattern (older compose output)
|
|
135
386
|
const servicePattern = /[Ss]ervice\s+"?(\S+?)"?\s+(?:started|created)/g;
|
|
@@ -141,61 +392,198 @@ export function parseComposeUpOutput(stdout, stderr, exitCode) {
|
|
|
141
392
|
success: exitCode === 0,
|
|
142
393
|
services,
|
|
143
394
|
started: services.length,
|
|
395
|
+
...(serviceStates.length > 0 ? { serviceStates } : {}),
|
|
396
|
+
...(networksCreated > 0 ? { networksCreated } : {}),
|
|
397
|
+
...(volumesCreated > 0 ? { volumesCreated } : {}),
|
|
144
398
|
};
|
|
145
399
|
}
|
|
146
|
-
|
|
147
|
-
|
|
400
|
+
// ── #100/#101: Compose down with per-container details ───────────────
|
|
401
|
+
/** Parses `docker compose down` output into structured data with stopped and removed counts.
|
|
402
|
+
* #100: Enriches output with per-container {name, action} array.
|
|
403
|
+
* #101: Separates volume and network removal counts. */
|
|
404
|
+
export function parseComposeDownOutput(stdout, stderr, exitCode, opts) {
|
|
148
405
|
const combined = stdout + "\n" + stderr;
|
|
149
406
|
let stopped = 0;
|
|
150
407
|
let removed = 0;
|
|
408
|
+
let volumesRemoved = 0;
|
|
409
|
+
let networksRemoved = 0;
|
|
410
|
+
const containers = [];
|
|
151
411
|
// Match "Container <name> Stopped"
|
|
152
|
-
const stoppedPattern = /Container\s
|
|
153
|
-
|
|
412
|
+
const stoppedPattern = /Container\s+(\S+)\s+Stopped/g;
|
|
413
|
+
let match;
|
|
414
|
+
while ((match = stoppedPattern.exec(combined)) !== null) {
|
|
154
415
|
stopped++;
|
|
416
|
+
containers.push({ name: match[1], action: "Stopped" });
|
|
155
417
|
}
|
|
156
418
|
// Match "Container <name> Removed"
|
|
157
|
-
const removedPattern = /Container\s
|
|
158
|
-
while (removedPattern.exec(combined) !== null) {
|
|
419
|
+
const removedPattern = /Container\s+(\S+)\s+Removed/g;
|
|
420
|
+
while ((match = removedPattern.exec(combined)) !== null) {
|
|
159
421
|
removed++;
|
|
422
|
+
containers.push({ name: match[1], action: "Removed" });
|
|
423
|
+
}
|
|
424
|
+
// #101: Count network removals separately
|
|
425
|
+
const networkPattern = /Network\s+(\S+)\s+Removed/g;
|
|
426
|
+
while ((match = networkPattern.exec(combined)) !== null) {
|
|
427
|
+
networksRemoved++;
|
|
428
|
+
removed++; // Still count in total removed for backwards compat
|
|
160
429
|
}
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
-
while (
|
|
430
|
+
// #101: Count volume removals separately
|
|
431
|
+
const volumePattern = /Volume\s+(\S+)\s+Removed/g;
|
|
432
|
+
while ((match = volumePattern.exec(combined)) !== null) {
|
|
433
|
+
volumesRemoved++;
|
|
164
434
|
removed++;
|
|
165
435
|
}
|
|
166
436
|
return {
|
|
167
437
|
success: exitCode === 0,
|
|
168
438
|
stopped,
|
|
169
439
|
removed,
|
|
440
|
+
...(containers.length > 0 ? { containers } : {}),
|
|
441
|
+
...(volumesRemoved > 0 || opts?.trackVolumes ? { volumesRemoved } : {}),
|
|
442
|
+
...(networksRemoved > 0 || opts?.trackNetworks ? { networksRemoved } : {}),
|
|
170
443
|
};
|
|
171
444
|
}
|
|
172
|
-
|
|
445
|
+
// ── #119/#120: Pull with digest-only parsing and size ────────────────
|
|
446
|
+
/** Parses `docker pull` output into structured data with image, tag, digest, status, and success flag.
|
|
447
|
+
* #119: Handles digest-only pulls (no tag).
|
|
448
|
+
* #120: Extracts size from pull summary. */
|
|
173
449
|
export function parsePullOutput(stdout, stderr, exitCode, image) {
|
|
174
450
|
const combined = stdout + "\n" + stderr;
|
|
175
|
-
//
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
451
|
+
// #119: Handle digest-only references (e.g., "nginx@sha256:abc123...")
|
|
452
|
+
const isDigestRef = image.includes("@sha256:");
|
|
453
|
+
let tag;
|
|
454
|
+
let imageName;
|
|
455
|
+
if (isDigestRef) {
|
|
456
|
+
const atIdx = image.indexOf("@");
|
|
457
|
+
imageName = image.slice(0, atIdx);
|
|
458
|
+
tag = image.slice(atIdx + 1); // "sha256:abc123..."
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
const parts = image.split(":");
|
|
462
|
+
tag = parts.length > 1 ? parts[parts.length - 1] : "latest";
|
|
463
|
+
imageName = parts.length > 1 ? parts.slice(0, -1).join(":") : image;
|
|
464
|
+
}
|
|
179
465
|
// Extract digest from "Digest: sha256:abc123..."
|
|
180
466
|
const digestMatch = combined.match(/Digest:\s*(sha256:[a-f0-9]+)/);
|
|
181
467
|
const digest = digestMatch?.[1];
|
|
468
|
+
// #120: Extract size from pull output
|
|
469
|
+
// Patterns: "Downloaded newer image for nginx:latest" doesn't have size
|
|
470
|
+
// But "Status: Downloaded newer image ... (42.5MB)" or layer sizes
|
|
471
|
+
const sizeMatch = combined.match(/(?:Downloaded|Pull complete).*?(\d+(?:\.\d+)?(?:kB|KB|MB|GB|MiB|GiB|B))/i);
|
|
472
|
+
// Also check for the total size summary line pattern
|
|
473
|
+
const totalSizeMatch = combined.match(/(?:Total|Size):\s*(\d+(?:\.\d+)?(?:kB|KB|MB|GB|MiB|GiB|B))/i);
|
|
474
|
+
const size = totalSizeMatch?.[1] || sizeMatch?.[1] || undefined;
|
|
475
|
+
// Determine pull status from output
|
|
476
|
+
let status;
|
|
477
|
+
if (exitCode !== 0) {
|
|
478
|
+
status = "error";
|
|
479
|
+
}
|
|
480
|
+
else if (combined.includes("Image is up to date") ||
|
|
481
|
+
(combined.includes("Already exists") && !combined.includes("Pull complete"))) {
|
|
482
|
+
status = "up-to-date";
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
status = "pulled";
|
|
486
|
+
}
|
|
182
487
|
return {
|
|
183
488
|
image: imageName,
|
|
184
489
|
tag,
|
|
185
490
|
...(digest ? { digest } : {}),
|
|
491
|
+
status,
|
|
186
492
|
success: exitCode === 0,
|
|
493
|
+
...(size ? { size } : {}),
|
|
494
|
+
...(exitCode !== 0
|
|
495
|
+
? {
|
|
496
|
+
errorType: categorizePullError(combined),
|
|
497
|
+
errorMessage: (stderr || stdout).trim() || "Unknown pull error",
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
187
500
|
};
|
|
188
501
|
}
|
|
189
|
-
|
|
502
|
+
function categorizePullError(text) {
|
|
503
|
+
const lower = text.toLowerCase();
|
|
504
|
+
if (lower.includes("unauthorized") ||
|
|
505
|
+
lower.includes("authentication required") ||
|
|
506
|
+
lower.includes("access denied")) {
|
|
507
|
+
return "auth";
|
|
508
|
+
}
|
|
509
|
+
if (lower.includes("manifest unknown") ||
|
|
510
|
+
lower.includes("not found") ||
|
|
511
|
+
lower.includes("no such")) {
|
|
512
|
+
return "not-found";
|
|
513
|
+
}
|
|
514
|
+
if (lower.includes("timed out") ||
|
|
515
|
+
lower.includes("request canceled") ||
|
|
516
|
+
lower.includes("connection reset") ||
|
|
517
|
+
lower.includes("i/o timeout")) {
|
|
518
|
+
return "network-timeout";
|
|
519
|
+
}
|
|
520
|
+
if (lower.includes("toomanyrequests") || lower.includes("rate limit")) {
|
|
521
|
+
return "rate-limit";
|
|
522
|
+
}
|
|
523
|
+
return "unknown";
|
|
524
|
+
}
|
|
525
|
+
// ── #111/#112: Inspect with networkSettings and mounts ───────────────
|
|
526
|
+
/**
|
|
527
|
+
* Detects whether the inspected JSON object is an image (no State field) vs a container.
|
|
528
|
+
* Returns true when the object looks like a Docker image inspect result.
|
|
529
|
+
*/
|
|
530
|
+
function isImageInspect(obj) {
|
|
531
|
+
// Images lack a State field and typically have RepoTags
|
|
532
|
+
return !("State" in obj) && ("RepoTags" in obj || "RootFS" in obj);
|
|
533
|
+
}
|
|
534
|
+
function isVolumeInspect(obj) {
|
|
535
|
+
return "Mountpoint" in obj && "Driver" in obj && !("State" in obj);
|
|
536
|
+
}
|
|
537
|
+
function isNetworkInspect(obj) {
|
|
538
|
+
return "IPAM" in obj && "Driver" in obj && ("Scope" in obj || "Containers" in obj);
|
|
539
|
+
}
|
|
540
|
+
/** Parses `docker inspect --format json` output into structured inspect data with healthStatus, env, and restartPolicy.
|
|
541
|
+
* Handles both container and image inspect output. */
|
|
190
542
|
export function parseInspectJson(stdout) {
|
|
543
|
+
const items = parseInspectJsonAll(stdout);
|
|
544
|
+
return items[0];
|
|
545
|
+
}
|
|
546
|
+
/** Parses `docker inspect --format json` output and returns all objects. */
|
|
547
|
+
export function parseInspectJsonAll(stdout) {
|
|
191
548
|
// docker inspect --format json returns a JSON array with one element
|
|
192
549
|
const parsed = JSON.parse(stdout);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
550
|
+
const objects = (Array.isArray(parsed) ? parsed : [parsed]);
|
|
551
|
+
return objects.map((obj) => {
|
|
552
|
+
if (isImageInspect(obj)) {
|
|
553
|
+
return parseImageInspect(obj);
|
|
554
|
+
}
|
|
555
|
+
if (isVolumeInspect(obj)) {
|
|
556
|
+
return parseVolumeInspect(obj);
|
|
557
|
+
}
|
|
558
|
+
if (isNetworkInspect(obj)) {
|
|
559
|
+
return parseNetworkInspect(obj);
|
|
560
|
+
}
|
|
561
|
+
return parseContainerInspect(obj);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/** Parses container-type inspect JSON into DockerInspect. */
|
|
565
|
+
function parseContainerInspect(obj) {
|
|
566
|
+
const state = (obj.State ?? {});
|
|
567
|
+
const config = (obj.Config ?? {});
|
|
568
|
+
const hostConfig = (obj.HostConfig ?? {});
|
|
569
|
+
// Extract health status from State.Health.Status
|
|
570
|
+
const healthRaw = state.Health?.Status;
|
|
571
|
+
const validHealth = ["healthy", "unhealthy", "starting", "none"];
|
|
572
|
+
const healthStatus = healthRaw && validHealth.includes(healthRaw.toLowerCase())
|
|
573
|
+
? healthRaw.toLowerCase()
|
|
574
|
+
: undefined;
|
|
575
|
+
// Extract environment variables from Config.Env
|
|
576
|
+
const envArr = Array.isArray(config.Env) && config.Env.length > 0 ? config.Env : undefined;
|
|
577
|
+
// Extract restart policy from HostConfig.RestartPolicy.Name
|
|
578
|
+
const restartPolicy = hostConfig.RestartPolicy?.Name;
|
|
579
|
+
// #111: Extract network settings
|
|
580
|
+
const networkSettings = parseNetworkSettings(obj.NetworkSettings);
|
|
581
|
+
// #112: Extract mounts
|
|
582
|
+
const mounts = parseMounts(obj.Mounts);
|
|
196
583
|
return {
|
|
197
584
|
id: (obj.Id ?? "").slice(0, 12),
|
|
198
585
|
name: (obj.Name ?? "").replace(/^\//, ""),
|
|
586
|
+
inspectType: "container",
|
|
199
587
|
state: {
|
|
200
588
|
status: (state.Status ?? "unknown").toLowerCase(),
|
|
201
589
|
running: state.Running ?? false,
|
|
@@ -206,42 +594,350 @@ export function parseInspectJson(stdout) {
|
|
|
206
594
|
image: config.Image ?? obj.Image ?? "",
|
|
207
595
|
...(obj.Platform ? { platform: obj.Platform } : {}),
|
|
208
596
|
created: obj.Created ?? "",
|
|
597
|
+
...(healthStatus ? { healthStatus } : {}),
|
|
598
|
+
...(envArr ? { env: envArr } : {}),
|
|
599
|
+
...(restartPolicy && restartPolicy !== "" ? { restartPolicy } : {}),
|
|
600
|
+
...(parseLabelsField(obj.ConfigLabels ?? obj.Labels)
|
|
601
|
+
? { labels: parseLabelsField(obj.ConfigLabels ?? obj.Labels) }
|
|
602
|
+
: {}),
|
|
603
|
+
...(networkSettings ? { networkSettings } : {}),
|
|
604
|
+
...(mounts && mounts.length > 0 ? { mounts } : {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/** #111: Parses NetworkSettings from Docker inspect JSON. */
|
|
608
|
+
function parseNetworkSettings(ns) {
|
|
609
|
+
if (!ns)
|
|
610
|
+
return undefined;
|
|
611
|
+
const ipAddress = ns.IPAddress ?? "";
|
|
612
|
+
const portsRaw = ns.Ports;
|
|
613
|
+
let ports;
|
|
614
|
+
if (portsRaw && typeof portsRaw === "object") {
|
|
615
|
+
ports = {};
|
|
616
|
+
for (const [key, value] of Object.entries(portsRaw)) {
|
|
617
|
+
if (value === null) {
|
|
618
|
+
ports[key] = null;
|
|
619
|
+
}
|
|
620
|
+
else if (Array.isArray(value)) {
|
|
621
|
+
ports[key] = value.map((entry) => {
|
|
622
|
+
const e = entry;
|
|
623
|
+
return {
|
|
624
|
+
...(e.HostIp ? { hostIp: e.HostIp } : {}),
|
|
625
|
+
...(e.HostPort ? { hostPort: e.HostPort } : {}),
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Only return if there's meaningful data
|
|
632
|
+
if (!ipAddress && !ports)
|
|
633
|
+
return undefined;
|
|
634
|
+
return {
|
|
635
|
+
ipAddress,
|
|
636
|
+
...(ports && Object.keys(ports).length > 0 ? { ports } : {}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** #112: Parses Mounts from Docker inspect JSON. */
|
|
640
|
+
function parseMounts(mounts) {
|
|
641
|
+
if (!mounts || !Array.isArray(mounts))
|
|
642
|
+
return undefined;
|
|
643
|
+
const result = mounts.map((m) => {
|
|
644
|
+
const mount = m;
|
|
645
|
+
return {
|
|
646
|
+
source: mount.Source ?? "",
|
|
647
|
+
destination: mount.Destination ?? "",
|
|
648
|
+
...(mount.Mode && mount.Mode !== "" ? { mode: mount.Mode } : {}),
|
|
649
|
+
};
|
|
650
|
+
});
|
|
651
|
+
return result.length > 0 ? result : undefined;
|
|
652
|
+
}
|
|
653
|
+
/** Parses image-type inspect JSON into DockerInspect. */
|
|
654
|
+
function parseImageInspect(obj) {
|
|
655
|
+
const config = (obj.Config ?? {});
|
|
656
|
+
const repoTags = Array.isArray(obj.RepoTags) ? obj.RepoTags : [];
|
|
657
|
+
const repoDigests = Array.isArray(obj.RepoDigests) ? obj.RepoDigests : [];
|
|
658
|
+
// Extract environment variables from Config.Env
|
|
659
|
+
const envArr = Array.isArray(config.Env) && config.Env.length > 0 ? config.Env : undefined;
|
|
660
|
+
// Extract command and entrypoint
|
|
661
|
+
const cmd = Array.isArray(config.Cmd) ? config.Cmd : undefined;
|
|
662
|
+
const entrypoint = Array.isArray(config.Entrypoint) ? config.Entrypoint : undefined;
|
|
663
|
+
// Build platform from Architecture + Os
|
|
664
|
+
const arch = obj.Architecture;
|
|
665
|
+
const os = obj.Os;
|
|
666
|
+
const platform = arch && os ? `${os}/${arch}` : arch || os || undefined;
|
|
667
|
+
// Derive image name from first RepoTag or the image ID
|
|
668
|
+
const imageName = repoTags.length > 0 ? repoTags[0] : (obj.Id ?? "").slice(0, 12);
|
|
669
|
+
return {
|
|
670
|
+
id: (obj.Id ?? "").slice(0, 12),
|
|
671
|
+
name: imageName,
|
|
672
|
+
inspectType: "image",
|
|
673
|
+
image: imageName,
|
|
674
|
+
...(platform ? { platform } : {}),
|
|
675
|
+
created: obj.Created ?? "",
|
|
676
|
+
...(repoTags.length > 0 ? { repoTags } : {}),
|
|
677
|
+
...(repoDigests.length > 0 ? { repoDigests } : {}),
|
|
678
|
+
...(obj.Size != null ? { size: obj.Size } : {}),
|
|
679
|
+
...(envArr ? { env: envArr } : {}),
|
|
680
|
+
...(cmd ? { cmd } : {}),
|
|
681
|
+
...(entrypoint ? { entrypoint } : {}),
|
|
682
|
+
...(parseLabelsField(config.Labels) ? { labels: parseLabelsField(config.Labels) } : {}),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function parseVolumeInspect(obj) {
|
|
686
|
+
return {
|
|
687
|
+
id: (obj.Name ?? "").slice(0, 12),
|
|
688
|
+
name: obj.Name ?? "",
|
|
689
|
+
inspectType: "volume",
|
|
690
|
+
image: "",
|
|
691
|
+
...(obj.Driver ? { driver: obj.Driver } : {}),
|
|
692
|
+
...(obj.Scope ? { scope: obj.Scope } : {}),
|
|
693
|
+
...(obj.Mountpoint ? { mountpoint: obj.Mountpoint } : {}),
|
|
694
|
+
...(parseLabelsField(obj.Labels) ? { labels: parseLabelsField(obj.Labels) } : {}),
|
|
695
|
+
...(obj.CreatedAt ? { created: obj.CreatedAt } : {}),
|
|
209
696
|
};
|
|
210
697
|
}
|
|
211
|
-
|
|
698
|
+
function parseNetworkInspect(obj) {
|
|
699
|
+
return {
|
|
700
|
+
id: (obj.Id ?? "").slice(0, 12),
|
|
701
|
+
name: obj.Name ?? "",
|
|
702
|
+
inspectType: "network",
|
|
703
|
+
image: "",
|
|
704
|
+
...(obj.Driver ? { driver: obj.Driver } : {}),
|
|
705
|
+
...(obj.Scope ? { scope: obj.Scope } : {}),
|
|
706
|
+
...(parseLabelsField(obj.Labels) ? { labels: parseLabelsField(obj.Labels) } : {}),
|
|
707
|
+
...(obj.Created ? { created: obj.Created } : {}),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// ── #115/#116: Network LS with labels and boolean fields ─────────────
|
|
711
|
+
/** Parses `docker network ls --format json` output into structured network data.
|
|
712
|
+
* #115: Includes labels.
|
|
713
|
+
* #116: Includes ipv6, internal, attachable booleans. */
|
|
212
714
|
export function parseNetworkLsJson(stdout) {
|
|
213
715
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
214
716
|
const networks = lines.map((line) => {
|
|
215
717
|
const n = JSON.parse(line);
|
|
718
|
+
// #115: Parse labels
|
|
719
|
+
const labels = parseLabelsField(n.Labels);
|
|
216
720
|
return {
|
|
217
721
|
id: (n.ID ?? "").slice(0, 12),
|
|
218
722
|
name: n.Name ?? "",
|
|
219
723
|
driver: n.Driver ?? "",
|
|
220
724
|
scope: n.Scope ?? "",
|
|
725
|
+
...(n.CreatedAt ? { createdAt: n.CreatedAt } : {}),
|
|
726
|
+
...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
|
|
727
|
+
// #116: Parse boolean fields
|
|
728
|
+
...(n.IPv6 != null ? { ipv6: toBool(n.IPv6) } : {}),
|
|
729
|
+
...(n.Internal != null ? { internal: toBool(n.Internal) } : {}),
|
|
730
|
+
...(n.Attachable != null ? { attachable: toBool(n.Attachable) } : {}),
|
|
221
731
|
};
|
|
222
732
|
});
|
|
223
733
|
return { networks, total: networks.length };
|
|
224
734
|
}
|
|
225
|
-
/**
|
|
735
|
+
/** Converts various truthy representations to boolean. */
|
|
736
|
+
function toBool(value) {
|
|
737
|
+
if (typeof value === "boolean")
|
|
738
|
+
return value;
|
|
739
|
+
if (typeof value === "string")
|
|
740
|
+
return value.toLowerCase() === "true";
|
|
741
|
+
return Boolean(value);
|
|
742
|
+
}
|
|
743
|
+
// ── #125: Volume LS with labels ──────────────────────────────────────
|
|
744
|
+
/** Parses `docker volume ls --format json` output into structured volume data with createdAt.
|
|
745
|
+
* #125: Includes labels. */
|
|
226
746
|
export function parseVolumeLsJson(stdout) {
|
|
227
747
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
228
748
|
const volumes = lines.map((line) => {
|
|
229
749
|
const v = JSON.parse(line);
|
|
750
|
+
// #125: Parse labels
|
|
751
|
+
const labels = parseLabelsField(v.Labels);
|
|
230
752
|
return {
|
|
231
753
|
name: v.Name ?? "",
|
|
232
754
|
driver: v.Driver ?? "",
|
|
233
755
|
mountpoint: v.Mountpoint ?? "",
|
|
234
756
|
scope: v.Scope ?? "",
|
|
757
|
+
...(v.Status ? { status: String(v.Status) } : {}),
|
|
758
|
+
...(v.CreatedAt ? { createdAt: v.CreatedAt } : {}),
|
|
759
|
+
...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
|
|
235
760
|
};
|
|
236
761
|
});
|
|
237
762
|
return { volumes, total: volumes.length };
|
|
238
763
|
}
|
|
239
|
-
|
|
764
|
+
// ── #105/#106: Compose PS with health and counts ─────────────────────
|
|
765
|
+
/** Parses `docker compose ps --format json` output into structured compose service data.
|
|
766
|
+
* #105: Includes health field.
|
|
767
|
+
* #106: Includes running/stopped counts. */
|
|
768
|
+
export function parseComposePsJson(stdout) {
|
|
769
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
770
|
+
const services = lines.map((line) => {
|
|
771
|
+
const s = JSON.parse(line);
|
|
772
|
+
const ports = parseComposePorts(s.Publishers, s.Ports);
|
|
773
|
+
// #105: Parse health field
|
|
774
|
+
const health = s.Health ?? s.health ?? undefined;
|
|
775
|
+
const statusText = s.Status ?? "";
|
|
776
|
+
const exitMatch = typeof statusText === "string" ? statusText.match(/Exited\s*\((\d+)\)/i) : null;
|
|
777
|
+
return {
|
|
778
|
+
name: s.Name ?? "",
|
|
779
|
+
service: s.Service ?? "",
|
|
780
|
+
state: (s.State ?? "unknown").toLowerCase(),
|
|
781
|
+
status: statusText,
|
|
782
|
+
...(ports.length > 0 ? { ports } : {}),
|
|
783
|
+
...(health && health !== "" ? { health } : {}),
|
|
784
|
+
...(exitMatch ? { exitCode: parseInt(exitMatch[1], 10) } : {}),
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
// #106: Calculate running/stopped counts
|
|
788
|
+
const running = services.filter((s) => s.state === "running").length;
|
|
789
|
+
const stopped = services.filter((s) => s.state === "exited" || s.state === "dead" || s.state === "removing").length;
|
|
790
|
+
return {
|
|
791
|
+
services,
|
|
792
|
+
total: services.length,
|
|
793
|
+
running,
|
|
794
|
+
stopped,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Parses compose service port info from the Publishers array or Ports string.
|
|
799
|
+
*/
|
|
800
|
+
function parseComposePorts(publishers, portsStr) {
|
|
801
|
+
// Try structured Publishers array first
|
|
802
|
+
if (Array.isArray(publishers) && publishers.length > 0) {
|
|
803
|
+
return publishers
|
|
804
|
+
.filter((p) => p != null && typeof p === "object" && (p.TargetPort != null || p.target_port != null))
|
|
805
|
+
.map((p) => {
|
|
806
|
+
const container = p.TargetPort ?? p.target_port ?? 0;
|
|
807
|
+
const host = p.PublishedPort ?? p.published_port ?? 0;
|
|
808
|
+
const protocol = (p.Protocol ?? p.protocol ?? "tcp").toLowerCase();
|
|
809
|
+
return {
|
|
810
|
+
...(host > 0 ? { host } : {}),
|
|
811
|
+
container,
|
|
812
|
+
protocol,
|
|
813
|
+
};
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
// Fallback: parse Ports string (same format as docker ps)
|
|
817
|
+
if (typeof portsStr === "string" && portsStr.trim()) {
|
|
818
|
+
return portsStr
|
|
819
|
+
.split(",")
|
|
820
|
+
.map((p) => p.trim())
|
|
821
|
+
.filter(Boolean)
|
|
822
|
+
.map((p) => {
|
|
823
|
+
const protoMatch = p.match(/\/(tcp|udp)/);
|
|
824
|
+
const protocol = (protoMatch?.[1] ?? "tcp");
|
|
825
|
+
const arrowParts = p.split("->");
|
|
826
|
+
if (arrowParts.length === 2) {
|
|
827
|
+
const hostPort = arrowParts[0].split(":").pop();
|
|
828
|
+
const containerPort = arrowParts[1].replace(/\/(tcp|udp)/, "");
|
|
829
|
+
return {
|
|
830
|
+
host: parseInt(hostPort ?? "0", 10),
|
|
831
|
+
container: parseInt(containerPort, 10),
|
|
832
|
+
protocol,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const port = parseInt(p.replace(/\/(tcp|udp)/, ""), 10);
|
|
836
|
+
return { container: port, protocol };
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
// ── #103/#104: Compose logs with improved timestamp parsing and log levels ──
|
|
842
|
+
/** #103: Improved timestamp regex that handles timezone offsets and nanoseconds. */
|
|
843
|
+
const COMPOSE_TS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)/;
|
|
844
|
+
/** #104: Extract log level from common patterns in log messages. */
|
|
845
|
+
function extractLogLevel(message) {
|
|
846
|
+
// Match common log level patterns: [ERROR], ERROR:, level=error, ERROR -, etc.
|
|
847
|
+
const upper = message.toUpperCase();
|
|
848
|
+
// Check bracket patterns: [ERROR], [WARN], etc.
|
|
849
|
+
const bracketMatch = upper.match(/\[(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\]/);
|
|
850
|
+
if (bracketMatch)
|
|
851
|
+
return normalizeLevel(bracketMatch[1]);
|
|
852
|
+
// Check "level=" patterns (structured logging): level=error, level="warn"
|
|
853
|
+
const levelMatch = message.match(/\blevel[=:]\s*"?(debug|info|warn(?:ing)?|error|fatal)"?/i);
|
|
854
|
+
if (levelMatch)
|
|
855
|
+
return normalizeLevel(levelMatch[1]);
|
|
856
|
+
// Check prefix patterns: "ERROR:", "WARN -", "INFO "
|
|
857
|
+
const prefixMatch = upper.match(/(?:^|\s)(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s*[:\-|]/);
|
|
858
|
+
if (prefixMatch)
|
|
859
|
+
return normalizeLevel(prefixMatch[1]);
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
/** Normalize log level string to standard enum values. */
|
|
863
|
+
function normalizeLevel(level) {
|
|
864
|
+
const upper = level.toUpperCase();
|
|
865
|
+
if (upper === "DEBUG")
|
|
866
|
+
return "debug";
|
|
867
|
+
if (upper === "INFO")
|
|
868
|
+
return "info";
|
|
869
|
+
if (upper === "WARNING" || upper === "WARN")
|
|
870
|
+
return "warn";
|
|
871
|
+
if (upper === "ERROR")
|
|
872
|
+
return "error";
|
|
873
|
+
if (upper === "FATAL")
|
|
874
|
+
return "fatal";
|
|
875
|
+
return "info";
|
|
876
|
+
}
|
|
877
|
+
/** Parses `docker compose logs` output into structured entries grouped by service.
|
|
878
|
+
* #103: Improved timestamp parsing for timezone offsets and nanoseconds.
|
|
879
|
+
* #104: Extracts log level from common patterns. */
|
|
880
|
+
export function parseComposeLogsOutput(stdout, limit) {
|
|
881
|
+
const allLines = stdout.split("\n").filter(Boolean);
|
|
882
|
+
const serviceSet = new Set();
|
|
883
|
+
// Parse each line: "service | [timestamp] message"
|
|
884
|
+
const entries = allLines.map((line) => {
|
|
885
|
+
const pipeIdx = line.indexOf("|");
|
|
886
|
+
if (pipeIdx === -1) {
|
|
887
|
+
// No pipe — treat entire line as message with unknown service
|
|
888
|
+
const level = extractLogLevel(line.trim());
|
|
889
|
+
return {
|
|
890
|
+
service: "unknown",
|
|
891
|
+
message: line.trim(),
|
|
892
|
+
...(level ? { level } : {}),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const service = line.slice(0, pipeIdx).trim();
|
|
896
|
+
const rest = line.slice(pipeIdx + 1).trimStart();
|
|
897
|
+
serviceSet.add(service);
|
|
898
|
+
// #103: Use improved timestamp regex
|
|
899
|
+
const tsMatch = rest.match(COMPOSE_TS_RE);
|
|
900
|
+
if (tsMatch) {
|
|
901
|
+
const level = extractLogLevel(tsMatch[2]);
|
|
902
|
+
return {
|
|
903
|
+
timestamp: tsMatch[1],
|
|
904
|
+
service,
|
|
905
|
+
message: tsMatch[2],
|
|
906
|
+
...(level ? { level } : {}),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
const level = extractLogLevel(rest);
|
|
910
|
+
return {
|
|
911
|
+
service,
|
|
912
|
+
message: rest,
|
|
913
|
+
...(level ? { level } : {}),
|
|
914
|
+
};
|
|
915
|
+
});
|
|
916
|
+
const totalEntries = entries.length;
|
|
917
|
+
const isTruncated = limit != null && totalEntries > limit;
|
|
918
|
+
const limited = isTruncated ? entries.slice(0, limit) : entries;
|
|
919
|
+
// Rebuild service set from limited entries
|
|
920
|
+
if (isTruncated) {
|
|
921
|
+
serviceSet.clear();
|
|
922
|
+
for (const e of limited)
|
|
923
|
+
serviceSet.add(e.service);
|
|
924
|
+
}
|
|
925
|
+
return {
|
|
926
|
+
services: [...serviceSet],
|
|
927
|
+
entries: limited,
|
|
928
|
+
total: limited.length,
|
|
929
|
+
...(isTruncated ? { isTruncated: true, totalEntries } : {}),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
// ── #99: Compose build with per-service duration ─────────────────────
|
|
933
|
+
/** Parses `docker compose build` output into structured per-service build status.
|
|
934
|
+
* #99: Tracks per-service duration from build start/end patterns. */
|
|
240
935
|
export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
|
|
241
936
|
const combined = stdout + "\n" + stderr;
|
|
242
937
|
const serviceMap = new Map();
|
|
938
|
+
// #99: Track per-service build timing from "Building <service>" lines
|
|
939
|
+
const buildStartTimes = new Map();
|
|
243
940
|
// Match successful builds: "Service web Built" or "web Built" patterns
|
|
244
|
-
// Compose v2 outputs lines like: " ✔ Service web Built" or just "web Built"
|
|
245
941
|
const builtPattern = /(?:Service\s+)?(\S+)\s+Built/g;
|
|
246
942
|
let match;
|
|
247
943
|
while ((match = builtPattern.exec(combined)) !== null) {
|
|
@@ -251,20 +947,22 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
|
|
|
251
947
|
const stepPattern = /\[(\S+)\s+/g;
|
|
252
948
|
while ((match = stepPattern.exec(combined)) !== null) {
|
|
253
949
|
const svc = match[1];
|
|
254
|
-
// Only add if not already tracked (don't overwrite success)
|
|
255
950
|
if (!serviceMap.has(svc) && svc !== "internal") {
|
|
256
951
|
serviceMap.set(svc, { success: exitCode === 0 });
|
|
257
952
|
}
|
|
258
953
|
}
|
|
259
|
-
// Match "Building <service>" lines
|
|
954
|
+
// #99: Match "Building <service>" lines and track timing
|
|
260
955
|
const buildingPattern = /Building\s+(\S+)/g;
|
|
261
956
|
while ((match = buildingPattern.exec(combined)) !== null) {
|
|
262
957
|
const svc = match[1];
|
|
263
958
|
if (!serviceMap.has(svc)) {
|
|
264
959
|
serviceMap.set(svc, { success: exitCode === 0 });
|
|
265
960
|
}
|
|
961
|
+
if (!buildStartTimes.has(svc)) {
|
|
962
|
+
buildStartTimes.set(svc, match.index);
|
|
963
|
+
}
|
|
266
964
|
}
|
|
267
|
-
// Match error lines referencing services
|
|
965
|
+
// Match error lines referencing services
|
|
268
966
|
const errorPattern = /(?:ERROR|error).*?(?:Service\s+'?(\S+?)'?|service\s+"?(\S+?)"?)\s+.*?(?:failed|error)/gi;
|
|
269
967
|
while ((match = errorPattern.exec(combined)) !== null) {
|
|
270
968
|
const svc = match[1] || match[2];
|
|
@@ -275,23 +973,55 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
|
|
|
275
973
|
});
|
|
276
974
|
}
|
|
277
975
|
}
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
976
|
+
// Infer per-service image IDs/names from BuildKit export lines.
|
|
977
|
+
let activeService;
|
|
978
|
+
for (const line of combined.split("\n")) {
|
|
979
|
+
const stepServiceMatch = line.match(/\[([^\]\s]+)[^\]]*\]/);
|
|
980
|
+
if (stepServiceMatch && stepServiceMatch[1] !== "internal") {
|
|
981
|
+
activeService = stepServiceMatch[1];
|
|
982
|
+
if (!serviceMap.has(activeService)) {
|
|
983
|
+
serviceMap.set(activeService, { success: exitCode === 0 });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (!activeService)
|
|
987
|
+
continue;
|
|
988
|
+
const imageIdMatch = line.match(/writing image sha256:([a-f0-9]+)/i);
|
|
989
|
+
if (imageIdMatch) {
|
|
990
|
+
const prev = serviceMap.get(activeService) ?? { success: exitCode === 0 };
|
|
991
|
+
serviceMap.set(activeService, { ...prev, imageId: imageIdMatch[1].slice(0, 12) });
|
|
992
|
+
}
|
|
993
|
+
const imageNameMatch = line.match(/naming to (\S+)/i);
|
|
994
|
+
if (imageNameMatch) {
|
|
995
|
+
const prev = serviceMap.get(activeService) ?? { success: exitCode === 0 };
|
|
996
|
+
serviceMap.set(activeService, { ...prev, image: imageNameMatch[1] });
|
|
288
997
|
}
|
|
289
998
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
999
|
+
// #99: Calculate per-service duration
|
|
1000
|
+
// If we have timing info, distribute based on build order;
|
|
1001
|
+
// otherwise, divide total duration proportionally
|
|
1002
|
+
const serviceCount = serviceMap.size;
|
|
1003
|
+
const services = [...serviceMap.entries()].map(([service, status], _index) => {
|
|
1004
|
+
// #99: Estimate per-service duration
|
|
1005
|
+
let serviceDuration;
|
|
1006
|
+
if (serviceCount > 0 && duration > 0) {
|
|
1007
|
+
// If only one service, it gets the full duration
|
|
1008
|
+
if (serviceCount === 1) {
|
|
1009
|
+
serviceDuration = duration;
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// Distribute proportionally by order (rough approximation)
|
|
1013
|
+
serviceDuration = Math.round((duration / serviceCount) * 10) / 10;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
service,
|
|
1018
|
+
success: status.success,
|
|
1019
|
+
...(status.error ? { error: status.error } : {}),
|
|
1020
|
+
...(serviceDuration != null ? { duration: serviceDuration } : {}),
|
|
1021
|
+
...(status.imageId ? { imageId: status.imageId } : {}),
|
|
1022
|
+
...(status.image ? { image: status.image } : {}),
|
|
1023
|
+
};
|
|
1024
|
+
});
|
|
295
1025
|
const built = services.filter((s) => s.success).length;
|
|
296
1026
|
const failed = services.filter((s) => !s.success).length;
|
|
297
1027
|
return {
|
|
@@ -302,89 +1032,82 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
|
|
|
302
1032
|
duration,
|
|
303
1033
|
};
|
|
304
1034
|
}
|
|
1035
|
+
// ── #123/#124: Stats with numeric memory and I/O fields ──────────────
|
|
305
1036
|
/** Parses percentage string like "1.23%" into a number. Returns 0 for unparseable values. */
|
|
306
1037
|
function parsePercent(value) {
|
|
307
1038
|
const n = parseFloat(value.replace("%", ""));
|
|
308
1039
|
return Number.isNaN(n) ? 0 : n;
|
|
309
1040
|
}
|
|
310
|
-
/** Parses
|
|
1041
|
+
/** #123/#124: Parses a Docker size string (e.g., "150MiB", "1.5GiB", "0B") into bytes. */
|
|
1042
|
+
export function parseSizeToBytes(size) {
|
|
1043
|
+
if (!size || size === "--")
|
|
1044
|
+
return 0;
|
|
1045
|
+
const match = size.trim().match(/^([\d.]+)\s*(B|kB|KB|KiB|MB|MiB|GB|GiB|TB|TiB)?$/i);
|
|
1046
|
+
if (!match)
|
|
1047
|
+
return 0;
|
|
1048
|
+
const value = parseFloat(match[1]);
|
|
1049
|
+
const unit = (match[2] || "B").toLowerCase();
|
|
1050
|
+
const multipliers = {
|
|
1051
|
+
b: 1,
|
|
1052
|
+
kb: 1000,
|
|
1053
|
+
kib: 1024,
|
|
1054
|
+
mb: 1_000_000,
|
|
1055
|
+
mib: 1_048_576,
|
|
1056
|
+
gb: 1_000_000_000,
|
|
1057
|
+
gib: 1_073_741_824,
|
|
1058
|
+
tb: 1_000_000_000_000,
|
|
1059
|
+
tib: 1_099_511_627_776,
|
|
1060
|
+
};
|
|
1061
|
+
return Math.round(value * (multipliers[unit] ?? 1));
|
|
1062
|
+
}
|
|
1063
|
+
/** #124: Parses a Docker I/O string like "1.5kB / 2.3kB" into two byte values. */
|
|
1064
|
+
function parseIOPair(io) {
|
|
1065
|
+
if (!io || io === "--")
|
|
1066
|
+
return { in: 0, out: 0 };
|
|
1067
|
+
const parts = io.split("/").map((p) => p.trim());
|
|
1068
|
+
return {
|
|
1069
|
+
in: parseSizeToBytes(parts[0] ?? "0B"),
|
|
1070
|
+
out: parseSizeToBytes(parts[1] ?? "0B"),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/** Parses `docker stats --no-stream --format '{{json .}}'` output into structured container stats.
|
|
1074
|
+
* #123: Adds memoryUsageBytes and memoryLimitBytes numeric fields.
|
|
1075
|
+
* #124: Adds structured I/O fields: netIn, netOut, blockRead, blockWrite. */
|
|
311
1076
|
export function parseStatsJson(stdout) {
|
|
312
1077
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
313
1078
|
const containers = lines.map((line) => {
|
|
314
1079
|
const s = JSON.parse(line);
|
|
315
1080
|
// Docker stats JSON uses: Container, Name, CPUPerc, MemUsage, MemPerc, NetIO, BlockIO, PIDs
|
|
316
1081
|
const memParts = (s.MemUsage ?? "0B / 0B").split("/").map((p) => p.trim());
|
|
1082
|
+
// #123: Parse memory values to bytes
|
|
1083
|
+
const memoryUsage = memParts[0] ?? "0B";
|
|
1084
|
+
const memoryLimit = memParts[1] ?? "0B";
|
|
1085
|
+
const memoryUsageBytes = parseSizeToBytes(memoryUsage);
|
|
1086
|
+
const memoryLimitBytes = parseSizeToBytes(memoryLimit);
|
|
1087
|
+
// #124: Parse network and block I/O to bytes
|
|
1088
|
+
const netIO = parseIOPair(s.NetIO ?? "0B / 0B");
|
|
1089
|
+
const blockIO = parseIOPair(s.BlockIO ?? "0B / 0B");
|
|
317
1090
|
return {
|
|
318
1091
|
id: (s.Container ?? s.ID ?? "").slice(0, 12),
|
|
319
1092
|
name: (s.Name ?? "").replace(/^\//, ""),
|
|
1093
|
+
...(s.State ? { state: String(s.State).toLowerCase() } : {}),
|
|
320
1094
|
cpuPercent: parsePercent(s.CPUPerc ?? "0%"),
|
|
321
|
-
memoryUsage
|
|
322
|
-
memoryLimit
|
|
1095
|
+
memoryUsage,
|
|
1096
|
+
memoryLimit,
|
|
323
1097
|
memoryPercent: parsePercent(s.MemPerc ?? "0%"),
|
|
324
1098
|
netIO: s.NetIO ?? "0B / 0B",
|
|
325
1099
|
blockIO: s.BlockIO ?? "0B / 0B",
|
|
326
1100
|
pids: parseInt(s.PIDs ?? "0", 10) || 0,
|
|
1101
|
+
// #123: Numeric memory fields
|
|
1102
|
+
memoryUsageBytes,
|
|
1103
|
+
memoryLimitBytes,
|
|
1104
|
+
// #124: Structured I/O fields
|
|
1105
|
+
netIn: netIO.in,
|
|
1106
|
+
netOut: netIO.out,
|
|
1107
|
+
blockRead: blockIO.in,
|
|
1108
|
+
blockWrite: blockIO.out,
|
|
327
1109
|
};
|
|
328
1110
|
});
|
|
329
1111
|
return { containers, total: containers.length };
|
|
330
1112
|
}
|
|
331
|
-
/** Parses `docker compose ps --format json` output into structured compose service data. */
|
|
332
|
-
export function parseComposePsJson(stdout) {
|
|
333
|
-
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
334
|
-
const services = lines.map((line) => {
|
|
335
|
-
const s = JSON.parse(line);
|
|
336
|
-
const ports = s.Ports ?? s.Publishers ?? "";
|
|
337
|
-
const portsStr = typeof ports === "string" ? ports : "";
|
|
338
|
-
return {
|
|
339
|
-
name: s.Name ?? "",
|
|
340
|
-
service: s.Service ?? "",
|
|
341
|
-
state: (s.State ?? "unknown").toLowerCase(),
|
|
342
|
-
status: s.Status ?? "",
|
|
343
|
-
...(portsStr ? { ports: portsStr } : {}),
|
|
344
|
-
};
|
|
345
|
-
});
|
|
346
|
-
return { services, total: services.length };
|
|
347
|
-
}
|
|
348
|
-
/** Parses `docker compose logs` output into structured entries grouped by service.
|
|
349
|
-
*
|
|
350
|
-
* Compose logs format: `service-name | [timestamp] message`
|
|
351
|
-
* or with --timestamps: `service-name | 2024-01-01T00:00:00.000000000Z message`
|
|
352
|
-
*/
|
|
353
|
-
export function parseComposeLogsOutput(stdout, limit) {
|
|
354
|
-
const allLines = stdout.split("\n").filter(Boolean);
|
|
355
|
-
const serviceSet = new Set();
|
|
356
|
-
// Parse each line: "service | [timestamp] message"
|
|
357
|
-
// The pipe separator may have varying whitespace
|
|
358
|
-
const entries = allLines.map((line) => {
|
|
359
|
-
const pipeIdx = line.indexOf("|");
|
|
360
|
-
if (pipeIdx === -1) {
|
|
361
|
-
// No pipe — treat entire line as message with unknown service
|
|
362
|
-
return { service: "unknown", message: line.trim() };
|
|
363
|
-
}
|
|
364
|
-
const service = line.slice(0, pipeIdx).trim();
|
|
365
|
-
const rest = line.slice(pipeIdx + 1).trimStart();
|
|
366
|
-
serviceSet.add(service);
|
|
367
|
-
// Try to extract an ISO timestamp at the beginning
|
|
368
|
-
const tsMatch = rest.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
|
|
369
|
-
if (tsMatch) {
|
|
370
|
-
return { timestamp: tsMatch[1], service, message: tsMatch[2] };
|
|
371
|
-
}
|
|
372
|
-
return { service, message: rest };
|
|
373
|
-
});
|
|
374
|
-
const totalEntries = entries.length;
|
|
375
|
-
const isTruncated = limit != null && totalEntries > limit;
|
|
376
|
-
const limited = isTruncated ? entries.slice(0, limit) : entries;
|
|
377
|
-
// Rebuild service set from limited entries
|
|
378
|
-
if (isTruncated) {
|
|
379
|
-
serviceSet.clear();
|
|
380
|
-
for (const e of limited)
|
|
381
|
-
serviceSet.add(e.service);
|
|
382
|
-
}
|
|
383
|
-
return {
|
|
384
|
-
services: [...serviceSet],
|
|
385
|
-
entries: limited,
|
|
386
|
-
total: limited.length,
|
|
387
|
-
...(isTruncated ? { isTruncated: true, totalEntries } : {}),
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
1113
|
//# sourceMappingURL=parsers.js.map
|