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