cdd-cli 3.1.7 → 3.2.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 (136) hide show
  1. package/.eslintrc.json +1 -1
  2. package/.github/workflows/ci.yml +3 -0
  3. package/.github/workflows/docs.yml +8 -1
  4. package/CHANGELOG.md +40 -0
  5. package/CONTRIBUTING.md +28 -15
  6. package/README.md +56 -88
  7. package/babel.config.cjs +18 -1
  8. package/coverage/clover.xml +604 -0
  9. package/coverage/coverage-final.json +17 -0
  10. package/coverage/lcov-report/base.css +224 -0
  11. package/coverage/lcov-report/block-navigation.js +87 -0
  12. package/coverage/lcov-report/favicon.png +0 -0
  13. package/coverage/lcov-report/helpers/constants.js.html +235 -0
  14. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +211 -0
  15. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +853 -0
  16. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +289 -0
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
  18. package/coverage/lcov-report/helpers/index.html +176 -0
  19. package/coverage/lcov-report/helpers/logger.js.html +424 -0
  20. package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
  21. package/coverage/lcov-report/helpers/validationHelpers.js.html +394 -0
  22. package/coverage/lcov-report/hooks/creation/index.html +146 -0
  23. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +292 -0
  24. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +478 -0
  25. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
  26. package/coverage/lcov-report/hooks/debug/index.html +116 -0
  27. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +172 -0
  28. package/coverage/lcov-report/hooks/index.html +161 -0
  29. package/coverage/lcov-report/hooks/navigation/index.html +116 -0
  30. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +160 -0
  31. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +415 -0
  32. package/coverage/lcov-report/hooks/useControls.js.html +685 -0
  33. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
  34. package/coverage/lcov-report/hooks/useExitHandler.js.html +283 -0
  35. package/coverage/lcov-report/index.html +191 -0
  36. package/coverage/lcov-report/prettify.css +1 -0
  37. package/coverage/lcov-report/prettify.js +2 -0
  38. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  39. package/coverage/lcov-report/sorter.js +210 -0
  40. package/coverage/lcov.info +1216 -0
  41. package/dist/components/ContainerRow.js +5 -78
  42. package/dist/components/LogViewer.js +4 -9
  43. package/dist/helpers/containerOptionsBuilder.js +58 -0
  44. package/dist/hooks/creation/useContainerCreation.js +16 -1
  45. package/dist/hooks/debug/useDebugLogs.js +46 -0
  46. package/dist/hooks/navigation/useContainerSelection.js +36 -0
  47. package/dist/hooks/useContainerCommandRouter.js +151 -0
  48. package/dist/{helpers/actionHelpers.js → hooks/useContainerStats.js} +76 -78
  49. package/dist/hooks/useControls.js +83 -342
  50. package/dist/hooks/useEraseConfirmation.js +47 -0
  51. package/dist/hooks/useExitHandler.js +68 -0
  52. package/jest.config.cjs +1 -1
  53. package/package.json +2 -3
  54. package/src/components/ContainerRow.jsx +3 -38
  55. package/src/components/LogViewer.jsx +4 -10
  56. package/src/helpers/constants.js +20 -0
  57. package/src/helpers/containerOptionsBuilder.js +42 -0
  58. package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -8
  59. package/src/helpers/validationHelpers.js +75 -18
  60. package/src/hooks/creation/useContainerCreation.js +34 -4
  61. package/src/hooks/debug/useDebugLogs.js +29 -0
  62. package/src/hooks/navigation/useContainerSelection.js +25 -0
  63. package/src/hooks/useContainerCommandRouter.js +110 -0
  64. package/src/hooks/useContainerStats.js +44 -0
  65. package/src/hooks/useControls.js +75 -274
  66. package/src/hooks/useEraseConfirmation.js +39 -0
  67. package/src/hooks/useExitHandler.js +66 -0
  68. package/test/containerActions.test.js +104 -13
  69. package/test/containerOptionsBuilder.test.js +36 -0
  70. package/test/useContainerCreation.dom.test.js +46 -2
  71. package/test/useControls.dom.test.js +117 -0
  72. package/test/validationHelpers.test.js +51 -0
  73. package/.babelrc +0 -6
  74. package/FIXES_APPLIED.md +0 -419
  75. package/SECURITY.md +0 -5
  76. package/dist/hooks/useLogsStream.js +0 -41
  77. package/docs/_config.yml +0 -13
  78. package/docs/components_ContainerCreationPrompt.jsx.html +0 -114
  79. package/docs/components_ContainerRow.jsx.html +0 -151
  80. package/docs/components_ContainerSection.jsx.html +0 -75
  81. package/docs/components_Footer.jsx.html +0 -66
  82. package/docs/components_Header.jsx.html +0 -84
  83. package/docs/components_LogViewer.jsx.html +0 -91
  84. package/docs/components_MessageFeedback.jsx.html +0 -76
  85. package/docs/components_PromptField.jsx.html +0 -99
  86. package/docs/components_StatsBar.jsx.html +0 -105
  87. package/docs/components_UsageMenu.jsx.html +0 -73
  88. package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
  89. package/docs/fonts/OpenSans-Bold-webfont.svg +0 -1830
  90. package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
  91. package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  92. package/docs/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
  93. package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  94. package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
  95. package/docs/fonts/OpenSans-Italic-webfont.svg +0 -1830
  96. package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
  97. package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
  98. package/docs/fonts/OpenSans-Light-webfont.svg +0 -1831
  99. package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
  100. package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  101. package/docs/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
  102. package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  103. package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
  104. package/docs/fonts/OpenSans-Regular-webfont.svg +0 -1831
  105. package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
  106. package/docs/global.html +0 -6145
  107. package/docs/helpers_actionHelpers.js.html +0 -94
  108. package/docs/helpers_constants.js.html +0 -81
  109. package/docs/helpers_dockerService_dockerService.js.html +0 -79
  110. package/docs/helpers_dockerService_serviceComponents_containerActions.js.html +0 -153
  111. package/docs/helpers_dockerService_serviceComponents_containerList.js.html +0 -81
  112. package/docs/helpers_dockerService_serviceComponents_containerLogs.js.html +0 -85
  113. package/docs/helpers_dockerService_serviceComponents_containerStats.js.html +0 -107
  114. package/docs/helpers_dockerService_serviceComponents_imageUtils.js.html +0 -82
  115. package/docs/helpers_exitWithMessage.js.html +0 -77
  116. package/docs/helpers_safeCall.js.html +0 -68
  117. package/docs/helpers_validationHelpers.js.html +0 -96
  118. package/docs/hooks_creation_useContainerActions.js.html +0 -120
  119. package/docs/hooks_creation_useContainerCreation.js.html +0 -152
  120. package/docs/hooks_creation_useLogsViewer.js.html +0 -102
  121. package/docs/hooks_useControls.js.html +0 -298
  122. package/docs/hooks_useLogsStream.js.html +0 -82
  123. package/docs/index.html +0 -65
  124. package/docs/index.js.html +0 -65
  125. package/docs/module-index.html +0 -171
  126. package/docs/scripts/linenumber.js +0 -25
  127. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
  128. package/docs/scripts/prettify/lang-css.js +0 -2
  129. package/docs/scripts/prettify/prettify.js +0 -28
  130. package/docs/styles/jsdoc-default.css +0 -358
  131. package/docs/styles/prettify-jsdoc.css +0 -111
  132. package/docs/styles/prettify-tomorrow.css +0 -132
  133. package/esbuild.config.cjs +0 -11
  134. package/src/helpers/actionHelpers.js +0 -52
  135. package/src/hooks/useLogsStream.js +0 -31
  136. package/test/actionHelpers.test.js +0 -106
@@ -1,9 +1,8 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React from "react";
2
2
  import { Box, Text } from "ink";
3
- import { getStats } from "../helpers/dockerService/serviceComponents/containerStats";
4
- import { REFRESH_INTERVALS } from "../helpers/constants.js";
5
3
  import StatsBar from "./StatsBar.jsx";
6
4
  import PropTypes from 'prop-types';
5
+ import { useContainerStats } from '../hooks/useContainerStats.js';
7
6
 
8
7
  const stateText = (state) => {
9
8
  if (state === "running") return { text: "🟢 RUNNING", color: "green" };
@@ -21,11 +20,7 @@ const stateText = (state) => {
21
20
  */
22
21
  export default function ContainerRow({ container, isSelected = false }) {
23
22
  const { id, name, image, state } = container;
24
- const [stats, setStats] = useState({
25
- cpuPercent: 0,
26
- memPercent: 0,
27
- netIO: { rx: 0, tx: 0 },
28
- });
23
+ const { stats, statsError } = useContainerStats(id, state);
29
24
 
30
25
  // Format ports for display (no leading space to avoid layout shifts)
31
26
  const formatPorts = (ports) => {
@@ -42,36 +37,6 @@ export default function ContainerRow({ container, isSelected = false }) {
42
37
  return s.length > max ? s.slice(0, max - 1) + "…" : s;
43
38
  };
44
39
 
45
- const [statsError, setStatsError] = useState("");
46
- useEffect(() => {
47
- if (state !== "running") return;
48
-
49
- let isMounted = true;
50
-
51
- const fetchStats = async () => {
52
- try {
53
- const s = await getStats(id);
54
- if (isMounted) {
55
- setStats(s);
56
- setStatsError("");
57
- }
58
- } catch (err) {
59
- if (isMounted) {
60
- setStats({ cpuPercent: 0, memPercent: 0, netIO: { rx: 0, tx: 0 } });
61
- setStatsError("Error fetching stats");
62
- }
63
- }
64
- };
65
-
66
- fetchStats();
67
- const timer = setInterval(fetchStats, REFRESH_INTERVALS.CONTAINER_STATS);
68
-
69
- return () => {
70
- isMounted = false;
71
- clearInterval(timer);
72
- };
73
- }, [id, state]);
74
-
75
40
  const stateInfo = stateText(state);
76
41
  return (
77
42
  <Box flexDirection="column" marginBottom={1}>
@@ -1,10 +1,10 @@
1
1
  import React from "react";
2
- import { Text, useInput } from "ink";
3
- import { safeCall } from "../helpers/safeCall";
2
+ import { Text } from "ink";
4
3
  import PropTypes from 'prop-types';
5
4
 
6
5
  /**
7
- * Log viewer overlay component. Shows the most recent lines and closes on ESC.
6
+ * Log viewer overlay component. Shows the most recent lines.
7
+ * Purely presentational — keyboard handling is centralized in useControls.
8
8
  *
9
9
  * @param {Object} props
10
10
  * @param {Array<string>} props.logs - Array of log lines
@@ -12,13 +12,7 @@ import PropTypes from 'prop-types';
12
12
  * @param {Object} [props.container] - Optional container metadata (used for title)
13
13
  * @returns {JSX.Element}
14
14
  */
15
- export default function LogViewer({ logs, onExit, container }) {
16
- useInput((input, key) => {
17
- if (key.escape) {
18
- safeCall(onExit);
19
- }
20
- });
21
-
15
+ export default function LogViewer({ logs, onExit: _onExit, container }) {
22
16
  const visibleLogs = logs.slice(-15);
23
17
 
24
18
  return (
@@ -28,3 +28,23 @@ export const TIMEOUTS = {
28
28
  CONTAINER_OP: 30000,
29
29
  PULL_IMAGE: 300000,
30
30
  };
31
+
32
+ /**
33
+ * List of well-known database image names (without tags or registry prefixes).
34
+ */
35
+ export const DB_IMAGES = ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"];
36
+
37
+ /**
38
+ * Per-image profiles with required env vars and default exposed port.
39
+ * Keys are normalized image names (no tag, no registry prefix).
40
+ *
41
+ * @type {Record<string, { requiredEnv: string[], defaultPort: string }>}
42
+ */
43
+ export const IMAGE_PROFILES = {
44
+ mysql: { requiredEnv: ["MYSQL_ROOT_PASSWORD"], defaultPort: "3306" },
45
+ mariadb: { requiredEnv: ["MARIADB_ROOT_PASSWORD"], defaultPort: "3306" },
46
+ postgres: { requiredEnv: ["POSTGRES_PASSWORD"], defaultPort: "5432" },
47
+ mongo: { requiredEnv: [], defaultPort: "27017" },
48
+ mssql: { requiredEnv: ["ACCEPT_EULA", "SA_PASSWORD"], defaultPort: "1433" },
49
+ redis: { requiredEnv: [], defaultPort: "6379" },
50
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Builds Docker container creation options from raw user inputs.
3
+ *
4
+ * This is a pure function — it has no side effects and calls no services.
5
+ * It mirrors exactly the option-building logic in useControls.js onCreate callback.
6
+ *
7
+ * @param {object} params
8
+ * @param {string} params.imageName - Docker image name (e.g. "nginx:alpine")
9
+ * @param {string} [params.containerName] - Optional container name
10
+ * @param {string} [params.portInput] - Comma-separated host:container port pairs
11
+ * (e.g. "8080:80,3000:3000")
12
+ * @param {string} [params.envInput] - Comma-separated KEY=VALUE pairs
13
+ * (e.g. "NODE_ENV=production,PORT=3000")
14
+ * @returns {object} Docker container options ready to pass to createContainer
15
+ */
16
+ export function buildContainerOptions({ imageName: _imageName, containerName, portInput, envInput }) {
17
+ const env = (envInput || "").split(",").map(s => s.trim()).filter(Boolean);
18
+ const ports = (portInput || "").split(",").map(s => s.trim()).filter(Boolean);
19
+
20
+ const ExposedPorts = {};
21
+ const PortBindings = {};
22
+
23
+ ports.forEach(pair => {
24
+ const [host, cont] = pair.split(":");
25
+ if (!host || !cont) return;
26
+ const key = `${cont}/tcp`;
27
+ ExposedPorts[key] = {};
28
+ PortBindings[key] = PortBindings[key] || [];
29
+ PortBindings[key].push({ HostPort: `${host}` });
30
+ });
31
+
32
+ const options = {
33
+ Tty: true,
34
+ };
35
+
36
+ if (Object.keys(ExposedPorts).length) options.ExposedPorts = ExposedPorts;
37
+ if (Object.keys(PortBindings).length) options.HostConfig = { PortBindings };
38
+ if (env.length) options.Env = env;
39
+ if (containerName) options.name = containerName;
40
+
41
+ return options;
42
+ }
@@ -1,6 +1,6 @@
1
1
  import { docker } from "../dockerService.js";
2
2
  import { imageExists, pullImage } from "./imageUtils.js";
3
- import { TIMEOUTS } from "../../constants.js";
3
+ import { TIMEOUTS, IMAGE_PROFILES } from "../../constants.js";
4
4
  import { logger } from "../../logger.js";
5
5
 
6
6
  /**
@@ -40,15 +40,33 @@ export async function removeContainer(containerId) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Normalize image name to base name (strips registry prefix and tag).
45
+ * e.g. "docker.io/library/postgres:16" → "postgres"
46
+ *
47
+ * @param {string} imageName - Raw image name
48
+ * @returns {string} Base name (lowercase)
49
+ */
50
+ function normalizeImageName(imageName) {
51
+ if (!imageName) return "";
52
+ let name = imageName;
53
+ const slashIdx = name.lastIndexOf("/");
54
+ if (slashIdx !== -1) name = name.slice(slashIdx + 1);
55
+ const colonIdx = name.indexOf(":");
56
+ if (colonIdx !== -1) name = name.slice(0, colonIdx);
57
+ return name.toLowerCase();
58
+ }
59
+
43
60
  /**
44
61
  * Create a new container from an image. If the image is missing locally, it will be pulled.
45
62
  *
46
63
  * @param {string} imageName - Image name (e.g. 'nginx:alpine')
47
64
  * @param {Object} [options] - Docker create options (Env, ExposedPorts, HostConfig, name, etc.)
48
- * @returns {Promise<string>} The created container id
65
+ * @param {Object} [imageProfiles] - Image profiles map for defaultPort fallback
66
+ * @returns {Promise<{id: string, ports: Array<{containerPort: string, hostPort: string, protocol: string, source: string}>}>}
49
67
  * @throws {Error} If image listing/pull or creation fails
50
68
  */
51
- export async function createContainer(imageName, options = {}) {
69
+ export async function createContainer(imageName, options = {}, imageProfiles = IMAGE_PROFILES) {
52
70
  logger.info("Creating container from image %s", imageName);
53
71
  let exists;
54
72
  try {
@@ -75,6 +93,9 @@ export async function createContainer(imageName, options = {}) {
75
93
 
76
94
  const hasUserDefinedPorts = Boolean(createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length);
77
95
 
96
+ /** @type {Array<{containerPort: string, hostPort: string, protocol: string, source: string}>} */
97
+ const assignedPorts = [];
98
+
78
99
  if (!hasUserDefinedPorts) {
79
100
  try {
80
101
  const image = docker.getImage(imageName);
@@ -101,7 +122,6 @@ export async function createContainer(imageName, options = {}) {
101
122
  const pickNextAvailablePort = (base) => {
102
123
  const numericBase = Number.parseInt(base, 10);
103
124
  if (Number.isNaN(numericBase)) {
104
- // If the base port is non-numeric, just return the base.
105
125
  if (!usedHostPorts.has(base)) {
106
126
  usedHostPorts.add(base);
107
127
  return base;
@@ -124,8 +144,20 @@ export async function createContainer(imageName, options = {}) {
124
144
  };
125
145
 
126
146
  const inspectData = await withTimeout(image.inspect(), TIMEOUTS.CONTAINER_OP);
127
- const exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
128
- const exposedKeys = Object.keys(exposed || {});
147
+ let exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
148
+ let exposedKeys = Object.keys(exposed || {});
149
+
150
+ // If no ExposedPorts in image, fall back to IMAGE_PROFILES defaultPort
151
+ if (!exposedKeys.length) {
152
+ const baseName = normalizeImageName(imageName);
153
+ const profile = imageProfiles[baseName];
154
+ if (profile && profile.defaultPort) {
155
+ const fallbackKey = `${profile.defaultPort}/tcp`;
156
+ exposed = { [fallbackKey]: {} };
157
+ exposedKeys = [fallbackKey];
158
+ }
159
+ }
160
+
129
161
  if (exposedKeys.length) {
130
162
  createOpts.ExposedPorts = createOpts.ExposedPorts || {};
131
163
  createOpts.HostConfig = { ...(createOpts.HostConfig || {}) };
@@ -135,9 +167,12 @@ export async function createContainer(imageName, options = {}) {
135
167
  createOpts.ExposedPorts[portKey] = exposed[portKey] || {};
136
168
  }
137
169
  if (!createOpts.HostConfig.PortBindings[portKey] || !createOpts.HostConfig.PortBindings[portKey].length) {
138
- const containerPort = portKey.split("/")[0];
170
+ const parts = portKey.split("/");
171
+ const containerPort = parts[0];
172
+ const protocol = parts[1] || "tcp";
139
173
  const hostPort = pickNextAvailablePort(containerPort);
140
174
  createOpts.HostConfig.PortBindings[portKey] = [{ HostPort: hostPort }];
175
+ assignedPorts.push({ containerPort, hostPort, protocol, source: "auto" });
141
176
  }
142
177
  });
143
178
  }
@@ -145,12 +180,24 @@ export async function createContainer(imageName, options = {}) {
145
180
  } catch (err) {
146
181
  logger.warn("Could not inspect image %s for default ports", imageName, err);
147
182
  }
183
+ } else {
184
+ // User-defined ports: collect them with source: "user"
185
+ const portBindings = createOpts.HostConfig?.PortBindings || {};
186
+ Object.entries(portBindings).forEach(([portKey, bindings]) => {
187
+ const parts = portKey.split("/");
188
+ const containerPort = parts[0];
189
+ const protocol = parts[1] || "tcp";
190
+ (bindings || []).forEach((b) => {
191
+ assignedPorts.push({ containerPort, hostPort: b.HostPort, protocol, source: "user" });
192
+ });
193
+ });
148
194
  }
195
+
149
196
  try {
150
197
  const container = await withTimeout(docker.createContainer(createOpts), TIMEOUTS.CONTAINER_OP);
151
198
  const containerId = container.id || container.Id;
152
199
  logger.info("Created container %s from image %s", containerId, imageName);
153
- return container.id || container.Id;
200
+ return { id: containerId, ports: assignedPorts };
154
201
  } catch (err) {
155
202
  logger.error("Failed to create container from image %s", imageName, err);
156
203
  throw new Error('Error creating container: ' + err.message);
@@ -19,28 +19,85 @@ export function validatePorts(portInput) {
19
19
  return !invalid;
20
20
  }
21
21
 
22
+ /**
23
+ * Normalize an image name to its base name (strips registry prefix and tag).
24
+ * e.g. "docker.io/library/postgres:16-alpine" → "postgres"
25
+ *
26
+ * @param {string} imageName - Raw image name from user
27
+ * @returns {string} Base image name (lowercase)
28
+ */
29
+ function normalizeImageName(imageName) {
30
+ if (!imageName) return "";
31
+ // Remove registry prefix (anything before the last / when it contains a dot or colon before the /)
32
+ let name = imageName;
33
+ const slashIdx = name.lastIndexOf("/");
34
+ if (slashIdx !== -1) {
35
+ name = name.slice(slashIdx + 1);
36
+ }
37
+ // Remove tag
38
+ const colonIdx = name.indexOf(":");
39
+ if (colonIdx !== -1) {
40
+ name = name.slice(0, colonIdx);
41
+ }
42
+ return name.toLowerCase();
43
+ }
44
+
22
45
  /**
23
46
  * Validate environment variable input as comma-separated VAR=value pairs.
24
- * Empty or whitespace-only input is considered valid (no env vars).
25
- * Variable names must start with a letter or underscore and contain only
26
- * alphanumeric characters and underscores.
47
+ *
48
+ * When called with 3 arguments (envInput, imageName, imageProfiles), performs
49
+ * contextual validation: checks that all required env vars for the given image
50
+ * are present and non-empty. Returns an object { valid, errors, parsedEnv }.
51
+ *
52
+ * When called with 1 argument (legacy), returns a boolean for backward compatibility.
27
53
  *
28
54
  * @param {string} envInput - Comma-separated environment variable assignments
29
- * @returns {boolean} True when all env var assignments are valid, false otherwise
55
+ * @param {string} [imageName] - Optional Docker image name for contextual validation
56
+ * @param {Object} [imageProfiles] - Optional map of image profiles (from constants.js)
57
+ * @returns {boolean|{valid: boolean, errors: string[], parsedEnv: Record<string,string>}}
30
58
  */
31
- export function validateEnvVars(envInput) {
32
- if (!envInput || !envInput.trim()) return true; // Empty is valid
33
-
34
- const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
35
- const invalid = vars.find(v => {
36
- const parts = v.split("=");
37
- // Must have at least VAR=value format
38
- if (parts.length < 2) return true;
39
- const varName = parts[0].trim();
40
- // Variable names should be alphanumeric with underscores
41
- if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) return true;
42
- return false;
43
- });
59
+ export function validateEnvVars(envInput, imageName, imageProfiles) {
60
+ const contextual = imageName !== undefined && imageProfiles !== undefined;
44
61
 
45
- return !invalid;
62
+ // Parse: split on FIRST '=' only
63
+ const parsedEnv = {};
64
+ const syntaxErrors = [];
65
+
66
+ if (envInput && envInput.trim()) {
67
+ const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
68
+ for (const v of vars) {
69
+ const eqIdx = v.indexOf("=");
70
+ if (eqIdx === -1) {
71
+ syntaxErrors.push(`"${v}" is missing an '=' sign`);
72
+ continue;
73
+ }
74
+ const varName = v.slice(0, eqIdx).trim();
75
+ const varValue = v.slice(eqIdx + 1);
76
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) {
77
+ syntaxErrors.push(`"${varName}" is not a valid variable name`);
78
+ continue;
79
+ }
80
+ parsedEnv[varName] = varValue;
81
+ }
82
+ }
83
+
84
+ if (!contextual) {
85
+ // Legacy: return boolean
86
+ return syntaxErrors.length === 0;
87
+ }
88
+
89
+ // Contextual: also check required env vars from the profile
90
+ const errors = [...syntaxErrors];
91
+ const baseName = normalizeImageName(imageName);
92
+ const profile = imageProfiles[baseName];
93
+
94
+ if (profile && profile.requiredEnv && profile.requiredEnv.length) {
95
+ for (const required of profile.requiredEnv) {
96
+ if (!parsedEnv[required] || parsedEnv[required].trim() === "") {
97
+ errors.push(`Missing required env var: ${required}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ return { valid: errors.length === 0, errors, parsedEnv };
46
103
  }
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
- import { validatePorts } from "../../helpers/validationHelpers.js";
2
+ import { validatePorts, validateEnvVars } from "../../helpers/validationHelpers.js";
3
+ import { IMAGE_PROFILES } from "../../helpers/constants.js";
3
4
  import { safeCall } from "../../helpers/safeCall.js";
4
5
 
5
6
  /**
@@ -9,10 +10,11 @@ import { safeCall } from "../../helpers/safeCall.js";
9
10
  * @param {Object} params
10
11
  * @param {Function} params.onCreate - Callback when creation is confirmed
11
12
  * @param {Function} params.onCancel - Callback when creation is cancelled
12
- * @param {Array<string>} params.dbImages - List of DB image names for env var warning
13
+ * @param {Array<string>} params.dbImages - List of DB image names for env var warning (legacy)
14
+ * @param {Object} [params.imageProfiles] - Image profiles map for contextual validation
13
15
  * @returns {Object} Creation state, setters, and helpers
14
16
  */
15
- export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
17
+ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageProfiles = IMAGE_PROFILES }) {
16
18
  const [step, setStep] = useState(0); // 0: image, 1: name, 2: ports, 3: env
17
19
  const [imageName, setImageName] = useState("");
18
20
  const [containerName, setContainerName] = useState("");
@@ -51,7 +53,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
51
53
  }
52
54
  setStep(3);
53
55
  const isDb = dbImages.some(db => imageName.trim().toLowerCase().includes(db));
54
- if (isDb) {
56
+ // Check if the image has a profile with required env vars
57
+ const baseName = imageName.trim().toLowerCase().split(":")[0].split("/").pop();
58
+ const profile = imageProfiles[baseName];
59
+ if (profile && profile.requiredEnv && profile.requiredEnv.length) {
60
+ setMessage(`Required env vars for ${baseName}: ${profile.requiredEnv.join(", ")}. Enter as VAR=val,VAR2=val2`);
61
+ setMessageColor("yellow");
62
+ } else if (isDb) {
55
63
  setMessage("Warning: This image usually requires environment variables (e.g. MYSQL_ROOT_PASSWORD=my-secret-pw for MySQL, POSTGRES_PASSWORD=yourpassword for Postgres). Enter them as VAR=val,VAR2=val2 or leave empty and press Enter.");
56
64
  setMessageColor("yellow");
57
65
  } else {
@@ -61,6 +69,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
61
69
  return;
62
70
  }
63
71
  if (step === 3) {
72
+ // Contextual env validation using image profiles
73
+ const result = validateEnvVars(envInput, imageName, imageProfiles);
74
+ if (!result.valid) {
75
+ setMessage(result.errors.join(" | "));
76
+ setMessageColor("red");
77
+ return;
78
+ }
64
79
  // Final step: call onCreate with all data (safely)
65
80
  safeCall(onCreate, { imageName, containerName, portInput, envInput });
66
81
  }
@@ -80,6 +95,20 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
80
95
  safeCall(onCancel);
81
96
  }
82
97
 
98
+ /**
99
+ * Resets all creation state to initial values without triggering onCancel.
100
+ * Used by the command router when the user presses 'c' to open the creation wizard.
101
+ */
102
+ function resetCreation() {
103
+ setStep(0);
104
+ setImageName("");
105
+ setContainerName("");
106
+ setPortInput("");
107
+ setEnvInput("");
108
+ setMessage("Insert the name of the image to create: ");
109
+ setMessageColor("yellow");
110
+ }
111
+
83
112
  return {
84
113
  step,
85
114
  setStep,
@@ -97,5 +126,6 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
97
126
  setMessageColor,
98
127
  nextStep,
99
128
  cancelCreation,
129
+ resetCreation,
100
130
  };
101
131
  }
@@ -0,0 +1,29 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { logger } from '../../helpers/logger.js';
3
+
4
+ export function useDebugLogs() {
5
+ const [showDebugLogs, setShowDebugLogs] = useState(false);
6
+ const [debugLogs, setDebugLogs] = useState([]);
7
+
8
+ useEffect(() => {
9
+ const unsubscribe = logger.subscribe((entry) => {
10
+ setDebugLogs((prev) => {
11
+ const extras = (entry.args || []).map((arg) => {
12
+ if (typeof arg === "string") return arg;
13
+ if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
14
+ try {
15
+ return JSON.stringify(arg);
16
+ } catch (err) {
17
+ return "[unserializable]";
18
+ }
19
+ }).filter(Boolean);
20
+ const line = extras.length ? `${entry.formatted} ${extras.join(" ")}` : entry.formatted;
21
+ const next = [...prev, line];
22
+ return next.slice(-200);
23
+ });
24
+ });
25
+ return unsubscribe;
26
+ }, []);
27
+
28
+ return { showDebugLogs, setShowDebugLogs, debugLogs };
29
+ }
@@ -0,0 +1,25 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ export function useContainerSelection(total) {
4
+ const [selected, setSelected] = useState(0);
5
+
6
+ const handleNavigation = useCallback((input, key) => {
7
+ if (total === 0) {
8
+ return false;
9
+ }
10
+
11
+ if (key.upArrow) {
12
+ setSelected((i) => (i === 0 ? total - 1 : i - 1));
13
+ return true;
14
+ }
15
+
16
+ if (key.downArrow) {
17
+ setSelected((i) => (i === total - 1 ? 0 : i + 1));
18
+ return true;
19
+ }
20
+
21
+ return false;
22
+ }, [total]);
23
+
24
+ return { selected, setSelected, handleNavigation };
25
+ }
@@ -0,0 +1,110 @@
1
+ import { useCallback } from "react";
2
+
3
+ /**
4
+ * Hook responsible solely for routing Docker action keystrokes to the correct handler.
5
+ * Has no domain state of its own — it delegates every side-effect to its dependencies.
6
+ *
7
+ * Handled keys:
8
+ * i — start container
9
+ * p — stop container
10
+ * r — restart container
11
+ * e — request erase confirmation
12
+ * l — open logs viewer and start log stream
13
+ * c — open container creation wizard (reset + activate)
14
+ * d — toggle debug log panel
15
+ *
16
+ * @param {Object} params
17
+ * @param {Object} params.actions - API from useContainerActions
18
+ * @param {Array<Object>} params.containers - Current list of Docker containers
19
+ * @param {number} params.selected - Index of the currently selected container
20
+ * @param {Object} params.creation - API from useContainerCreation (must expose resetCreation)
21
+ * @param {Object} params.logsViewer - API from useLogsViewer
22
+ * @param {Function} params.startLogsStream - Starts the log stream for a container id
23
+ * @param {Function} params.onStartErase - Callback: activates confirmErase state
24
+ * @param {Function} params.onToggleDebug - Callback: toggles showDebugLogs state
25
+ * @param {Function} params.onStartCreate - Callback: sets creatingContainer = true
26
+ * @returns {{ handleDockerCommands: Function }}
27
+ */
28
+ export function useContainerCommandRouter({
29
+ actions,
30
+ containers,
31
+ selected,
32
+ creation,
33
+ logsViewer,
34
+ startLogsStream,
35
+ onStartErase,
36
+ onToggleDebug,
37
+ onStartCreate,
38
+ }) {
39
+ const handleDockerCommands = useCallback((input) => {
40
+ const container = containers[selected];
41
+
42
+ if (input === "i") {
43
+ actions.handleAction({
44
+ actionFn: async (id) => await actions.startContainer(id),
45
+ actionLabel: "Starting",
46
+ selected,
47
+ stateCheck: (c) =>
48
+ (c.state === "running" || c.status === "running") &&
49
+ "Container is already running.",
50
+ });
51
+ return true;
52
+ }
53
+
54
+ if (input === "p") {
55
+ actions.handleAction({
56
+ actionFn: async (id) => await actions.stopContainer(id),
57
+ actionLabel: "Stopping",
58
+ selected,
59
+ stateCheck: (c) =>
60
+ (c.state === "exited" ||
61
+ c.status === "exited" ||
62
+ c.state === "stopped" ||
63
+ c.status === "stopped") &&
64
+ "Container is already stopped.",
65
+ });
66
+ return true;
67
+ }
68
+
69
+ if (input === "r") {
70
+ actions.handleAction({
71
+ actionFn: async (id) => await actions.restartContainer(id),
72
+ actionLabel: "Restarting",
73
+ selected,
74
+ });
75
+ return true;
76
+ }
77
+
78
+ if (input === "e") {
79
+ if (!container) {
80
+ return true;
81
+ }
82
+ onStartErase();
83
+ return true;
84
+ }
85
+
86
+ if (input === "l") {
87
+ if (!container) {
88
+ return true;
89
+ }
90
+ logsViewer.openLogs();
91
+ startLogsStream(container.id);
92
+ return true;
93
+ }
94
+
95
+ if (input === "c") {
96
+ onStartCreate();
97
+ creation.resetCreation();
98
+ return true;
99
+ }
100
+
101
+ if (input === "d") {
102
+ onToggleDebug();
103
+ return true;
104
+ }
105
+
106
+ return false;
107
+ }, [actions, containers, creation, logsViewer, onStartCreate, onStartErase, onToggleDebug, selected, startLogsStream]);
108
+
109
+ return { handleDockerCommands };
110
+ }
@@ -0,0 +1,44 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { getStats } from '../helpers/dockerService/serviceComponents/containerStats.js';
3
+ import { REFRESH_INTERVALS } from '../helpers/constants.js';
4
+
5
+ const DEFAULT_STATS = { cpuPercent: 0, memPercent: 0, netIO: { rx: 0, tx: 0 } };
6
+
7
+ export function useContainerStats(id, state) {
8
+ const [stats, setStats] = useState(DEFAULT_STATS);
9
+ const [statsError, setStatsError] = useState('');
10
+
11
+ useEffect(() => {
12
+ if (state !== 'running') {
13
+ setStats(DEFAULT_STATS);
14
+ setStatsError('');
15
+ return;
16
+ }
17
+
18
+ let isMounted = true;
19
+
20
+ const fetchStats = async () => {
21
+ try {
22
+ const s = await getStats(id);
23
+ if (isMounted) {
24
+ setStats(s);
25
+ setStatsError('');
26
+ }
27
+ } catch (err) {
28
+ if (isMounted) {
29
+ setStats(DEFAULT_STATS);
30
+ setStatsError('Error fetching stats');
31
+ }
32
+ }
33
+ };
34
+
35
+ fetchStats();
36
+ const timer = setInterval(fetchStats, REFRESH_INTERVALS.CONTAINER_STATS);
37
+ return () => {
38
+ isMounted = false;
39
+ clearInterval(timer);
40
+ };
41
+ }, [id, state]);
42
+
43
+ return { stats, statsError };
44
+ }