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.
- package/.eslintrc.json +1 -1
- package/.github/workflows/ci.yml +3 -0
- package/.github/workflows/docs.yml +8 -1
- package/CHANGELOG.md +40 -0
- package/CONTRIBUTING.md +28 -15
- package/README.md +56 -88
- package/babel.config.cjs +18 -1
- package/coverage/clover.xml +604 -0
- package/coverage/coverage-final.json +17 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/helpers/constants.js.html +235 -0
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +211 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +853 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +289 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
- package/coverage/lcov-report/helpers/index.html +176 -0
- package/coverage/lcov-report/helpers/logger.js.html +424 -0
- package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
- package/coverage/lcov-report/helpers/validationHelpers.js.html +394 -0
- package/coverage/lcov-report/hooks/creation/index.html +146 -0
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +292 -0
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +478 -0
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
- package/coverage/lcov-report/hooks/debug/index.html +116 -0
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +172 -0
- package/coverage/lcov-report/hooks/index.html +161 -0
- package/coverage/lcov-report/hooks/navigation/index.html +116 -0
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +160 -0
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +415 -0
- package/coverage/lcov-report/hooks/useControls.js.html +685 -0
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
- package/coverage/lcov-report/hooks/useExitHandler.js.html +283 -0
- package/coverage/lcov-report/index.html +191 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +1216 -0
- package/dist/components/ContainerRow.js +5 -78
- package/dist/components/LogViewer.js +4 -9
- package/dist/helpers/containerOptionsBuilder.js +58 -0
- package/dist/hooks/creation/useContainerCreation.js +16 -1
- package/dist/hooks/debug/useDebugLogs.js +46 -0
- package/dist/hooks/navigation/useContainerSelection.js +36 -0
- package/dist/hooks/useContainerCommandRouter.js +151 -0
- package/dist/{helpers/actionHelpers.js → hooks/useContainerStats.js} +76 -78
- package/dist/hooks/useControls.js +83 -342
- package/dist/hooks/useEraseConfirmation.js +47 -0
- package/dist/hooks/useExitHandler.js +68 -0
- package/jest.config.cjs +1 -1
- package/package.json +2 -3
- package/src/components/ContainerRow.jsx +3 -38
- package/src/components/LogViewer.jsx +4 -10
- package/src/helpers/constants.js +20 -0
- package/src/helpers/containerOptionsBuilder.js +42 -0
- package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -8
- package/src/helpers/validationHelpers.js +75 -18
- package/src/hooks/creation/useContainerCreation.js +34 -4
- package/src/hooks/debug/useDebugLogs.js +29 -0
- package/src/hooks/navigation/useContainerSelection.js +25 -0
- package/src/hooks/useContainerCommandRouter.js +110 -0
- package/src/hooks/useContainerStats.js +44 -0
- package/src/hooks/useControls.js +75 -274
- package/src/hooks/useEraseConfirmation.js +39 -0
- package/src/hooks/useExitHandler.js +66 -0
- package/test/containerActions.test.js +104 -13
- package/test/containerOptionsBuilder.test.js +36 -0
- package/test/useContainerCreation.dom.test.js +46 -2
- package/test/useControls.dom.test.js +117 -0
- package/test/validationHelpers.test.js +51 -0
- package/.babelrc +0 -6
- package/FIXES_APPLIED.md +0 -419
- package/SECURITY.md +0 -5
- package/dist/hooks/useLogsStream.js +0 -41
- package/docs/_config.yml +0 -13
- package/docs/components_ContainerCreationPrompt.jsx.html +0 -114
- package/docs/components_ContainerRow.jsx.html +0 -151
- package/docs/components_ContainerSection.jsx.html +0 -75
- package/docs/components_Footer.jsx.html +0 -66
- package/docs/components_Header.jsx.html +0 -84
- package/docs/components_LogViewer.jsx.html +0 -91
- package/docs/components_MessageFeedback.jsx.html +0 -76
- package/docs/components_PromptField.jsx.html +0 -99
- package/docs/components_StatsBar.jsx.html +0 -105
- package/docs/components_UsageMenu.jsx.html +0 -73
- package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Bold-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.svg +0 -1830
- package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Light-webfont.svg +0 -1831
- package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
- package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.svg +0 -1831
- package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
- package/docs/global.html +0 -6145
- package/docs/helpers_actionHelpers.js.html +0 -94
- package/docs/helpers_constants.js.html +0 -81
- package/docs/helpers_dockerService_dockerService.js.html +0 -79
- package/docs/helpers_dockerService_serviceComponents_containerActions.js.html +0 -153
- package/docs/helpers_dockerService_serviceComponents_containerList.js.html +0 -81
- package/docs/helpers_dockerService_serviceComponents_containerLogs.js.html +0 -85
- package/docs/helpers_dockerService_serviceComponents_containerStats.js.html +0 -107
- package/docs/helpers_dockerService_serviceComponents_imageUtils.js.html +0 -82
- package/docs/helpers_exitWithMessage.js.html +0 -77
- package/docs/helpers_safeCall.js.html +0 -68
- package/docs/helpers_validationHelpers.js.html +0 -96
- package/docs/hooks_creation_useContainerActions.js.html +0 -120
- package/docs/hooks_creation_useContainerCreation.js.html +0 -152
- package/docs/hooks_creation_useLogsViewer.js.html +0 -102
- package/docs/hooks_useControls.js.html +0 -298
- package/docs/hooks_useLogsStream.js.html +0 -82
- package/docs/index.html +0 -65
- package/docs/index.js.html +0 -65
- package/docs/module-index.html +0 -171
- package/docs/scripts/linenumber.js +0 -25
- package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
- package/docs/scripts/prettify/lang-css.js +0 -2
- package/docs/scripts/prettify/prettify.js +0 -28
- package/docs/styles/jsdoc-default.css +0 -358
- package/docs/styles/prettify-jsdoc.css +0 -111
- package/docs/styles/prettify-tomorrow.css +0 -132
- package/esbuild.config.cjs +0 -11
- package/src/helpers/actionHelpers.js +0 -52
- package/src/hooks/useLogsStream.js +0 -31
- package/test/actionHelpers.test.js +0 -106
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import 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
|
|
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
|
|
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
|
|
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 (
|
package/src/helpers/constants.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|