@paretools/docker 0.8.4 → 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.
Files changed (61) hide show
  1. package/dist/lib/formatters.d.ts +51 -21
  2. package/dist/lib/formatters.d.ts.map +1 -1
  3. package/dist/lib/formatters.js +234 -41
  4. package/dist/lib/formatters.js.map +1 -1
  5. package/dist/lib/parsers.d.ts +41 -22
  6. package/dist/lib/parsers.d.ts.map +1 -1
  7. package/dist/lib/parsers.js +673 -125
  8. package/dist/lib/parsers.js.map +1 -1
  9. package/dist/schemas/index.d.ts +213 -9
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +145 -7
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/tools/build.d.ts.map +1 -1
  14. package/dist/tools/build.js +120 -8
  15. package/dist/tools/build.js.map +1 -1
  16. package/dist/tools/compose-build.d.ts.map +1 -1
  17. package/dist/tools/compose-build.js +65 -2
  18. package/dist/tools/compose-build.js.map +1 -1
  19. package/dist/tools/compose-down.d.ts.map +1 -1
  20. package/dist/tools/compose-down.js +34 -1
  21. package/dist/tools/compose-down.js.map +1 -1
  22. package/dist/tools/compose-logs.d.ts.map +1 -1
  23. package/dist/tools/compose-logs.js +44 -2
  24. package/dist/tools/compose-logs.js.map +1 -1
  25. package/dist/tools/compose-ps.d.ts.map +1 -1
  26. package/dist/tools/compose-ps.js +64 -3
  27. package/dist/tools/compose-ps.js.map +1 -1
  28. package/dist/tools/compose-up.d.ts.map +1 -1
  29. package/dist/tools/compose-up.js +65 -1
  30. package/dist/tools/compose-up.js.map +1 -1
  31. package/dist/tools/exec.d.ts.map +1 -1
  32. package/dist/tools/exec.js +38 -7
  33. package/dist/tools/exec.js.map +1 -1
  34. package/dist/tools/images.d.ts.map +1 -1
  35. package/dist/tools/images.js +33 -7
  36. package/dist/tools/images.js.map +1 -1
  37. package/dist/tools/inspect.d.ts.map +1 -1
  38. package/dist/tools/inspect.js +16 -2
  39. package/dist/tools/inspect.js.map +1 -1
  40. package/dist/tools/logs.d.ts.map +1 -1
  41. package/dist/tools/logs.js +34 -7
  42. package/dist/tools/logs.js.map +1 -1
  43. package/dist/tools/network-ls.d.ts.map +1 -1
  44. package/dist/tools/network-ls.js +23 -2
  45. package/dist/tools/network-ls.js.map +1 -1
  46. package/dist/tools/ps.d.ts.map +1 -1
  47. package/dist/tools/ps.js +21 -2
  48. package/dist/tools/ps.js.map +1 -1
  49. package/dist/tools/pull.d.ts.map +1 -1
  50. package/dist/tools/pull.js +15 -1
  51. package/dist/tools/pull.js.map +1 -1
  52. package/dist/tools/run.d.ts.map +1 -1
  53. package/dist/tools/run.js +109 -4
  54. package/dist/tools/run.js.map +1 -1
  55. package/dist/tools/stats.d.ts.map +1 -1
  56. package/dist/tools/stats.js +21 -2
  57. package/dist/tools/stats.js.map +1 -1
  58. package/dist/tools/volume-ls.d.ts.map +1 -1
  59. package/dist/tools/volume-ls.js +23 -2
  60. package/dist/tools/volume-ls.js.map +1 -1
  61. package/package.json +2 -2
@@ -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: (c.ID ?? c.Id ?? "").slice(0, 12),
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
- const errorLines = (stdout + "\n" + stderr)
58
- .split("\n")
59
- .filter((l) => l.match(/error|ERROR|Error/) && !l.match(/^#\d+/));
60
- errors.push(...errorLines.map((l) => l.trim()).filter(Boolean));
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
- /** Parses `docker logs` output into structured data with container name and log lines. Caps output to `limit` lines when provided. */
73
- export function parseLogsOutput(stdout, container, limit) {
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
- /** Parses `docker images --format json` output into structured image data with repository, tag, and size. */
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
- /** Parses `docker run` output into structured data with container ID and image info. */
101
- export function parseRunOutput(stdout, image, detached, name) {
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
- return {
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
- /** Parses `docker exec` output into structured data with exit code, stdout, stderr, and success. */
112
- export function parseExecOutput(stdout, stderr, exitCode) {
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
- /** Parses `docker compose up` output into structured data with service names and started count. */
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
- // Match "Container <name> Started" or "Container <name> Running"
129
- const startedPattern = /Container\s+(\S+)\s+(?:Started|Running|Created)/g;
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 = startedPattern.exec(combined)) !== null) {
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
- /** Parses `docker compose down` output into structured data with stopped and removed counts. */
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+\S+\s+Stopped/g;
153
- while (stoppedPattern.exec(combined) !== null) {
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+\S+\s+Removed/g;
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
- // Also count network/volume removals as part of removed
162
- const networkPattern = /Network\s+\S+\s+Removed/g;
163
- while (networkPattern.exec(combined) !== null) {
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
- /** Parses `docker pull` output into structured data with image, tag, digest, and success flag. */
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
- // Extract tag from image string (e.g., "nginx:latest" -> "latest", "nginx" -> "latest")
176
- const parts = image.split(":");
177
- const tag = parts.length > 1 ? parts[parts.length - 1] : "latest";
178
- const imageName = parts.length > 1 ? parts.slice(0, -1).join(":") : image;
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
- /** Parses `docker inspect --format json` output into structured inspect data. */
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
- const state = obj.State ?? {};
195
- const config = obj.Config ?? {};
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
- /** Parses `docker network ls --format json` output into structured network data. */
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
- /** Parses `docker volume ls --format json` output into structured volume data. */
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
- /** Parses `docker compose build` output into structured per-service build status. */
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 (compose v2 progress output)
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: "failed to solve: ..." or "ERROR: Service 'web' failed to build"
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
- // If no services were detected but there's output and success, try to infer
279
- // If the build failed and no services found, create a generic entry from error
280
- if (serviceMap.size === 0 && exitCode !== 0) {
281
- const errorLines = combined
282
- .split("\n")
283
- .filter((l) => l.match(/error|ERROR|failed/i))
284
- .map((l) => l.trim())
285
- .filter(Boolean);
286
- if (errorLines.length > 0) {
287
- // Cannot determine service name — leave services empty
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
- const services = [...serviceMap.entries()].map(([service, status]) => ({
291
- service,
292
- success: status.success,
293
- ...(status.error ? { error: status.error } : {}),
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 `docker stats --no-stream --format '{{json .}}'` output into structured container stats. */
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: memParts[0] ?? "0B",
322
- memoryLimit: memParts[1] ?? "0B",
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