cdd-cli 3.2.0 → 4.0.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/.atl/skill-registry.md +30 -0
- package/.editorconfig +12 -0
- package/.eslintrc.json +1 -1
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/.~lock.ROADMAP.md# +1 -0
- package/CHANGELOG.md +80 -0
- package/README.es.md +198 -0
- package/README.md +81 -23
- package/ROADMAP.md +647 -0
- package/SECURITY.md +5 -0
- package/__mocks__/ink.cjs +17 -0
- package/coverage/clover.xml +471 -500
- package/coverage/coverage-final.json +16 -16
- package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
- package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +394 -0
- package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +268 -0
- package/coverage/lcov-report/components/index.html +146 -0
- package/coverage/lcov-report/helpers/constants.js.html +139 -22
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +75 -42
- package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
- package/coverage/lcov-report/helpers/index.html +84 -54
- package/coverage/lcov-report/helpers/logger.js.html +68 -68
- package/coverage/lcov-report/helpers/safeCall.js.html +15 -15
- package/coverage/lcov-report/helpers/validationHelpers.js.html +66 -114
- package/coverage/lcov-report/hooks/creation/index.html +20 -20
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +593 -89
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
- package/coverage/lcov-report/hooks/debug/index.html +5 -5
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
- package/coverage/lcov-report/hooks/index.html +40 -40
- package/coverage/lcov-report/hooks/navigation/index.html +19 -19
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
- package/coverage/lcov-report/hooks/useControls.js.html +306 -123
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
- package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
- package/coverage/lcov-report/index.html +52 -67
- package/coverage/lcov.info +899 -952
- package/dist/App.js +18 -12
- package/dist/components/ContainerCreationPrompt.js +50 -13
- package/dist/components/ContainerList.js +3 -3
- package/dist/components/ContainerRow.js +20 -20
- package/dist/components/ContainerSection.js +3 -3
- package/dist/components/ControlsHUD.js +106 -0
- package/dist/components/Footer.js +2 -2
- package/dist/components/Header.js +7 -7
- package/dist/components/LogViewer.js +3 -3
- package/dist/components/MessageFeedback.js +2 -2
- package/dist/components/PromptField.js +4 -4
- package/dist/components/StatsBar.js +5 -5
- package/dist/components/SuggestionPanel.js +53 -0
- package/dist/components/UsageMenu.js +3 -3
- package/dist/helpers/appInfo.js +7 -7
- package/dist/helpers/constants.js +154 -1
- package/dist/helpers/containerOptionsBuilder.js +3 -3
- package/dist/helpers/dockerHubService.js +111 -0
- package/dist/helpers/dockerService/dockerService.js +1 -1
- package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
- package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
- package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
- package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
- package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
- package/dist/helpers/exitWithMessage.js +10 -10
- package/dist/helpers/imageNameUtils.js +32 -0
- package/dist/helpers/logger.js +13 -13
- package/dist/helpers/safeCall.js +2 -2
- package/dist/helpers/validationHelpers.js +76 -21
- package/dist/hooks/creation/useContainerActions.js +28 -11
- package/dist/hooks/creation/useContainerCreation.js +452 -62
- package/dist/hooks/creation/useLogsViewer.js +2 -2
- package/dist/hooks/debug/useDebugLogs.js +4 -4
- package/dist/hooks/useContainerCommandRouter.js +14 -14
- package/dist/hooks/useContainers.js +3 -3
- package/dist/hooks/useControls.js +85 -38
- package/dist/hooks/useEraseConfirmation.js +3 -3
- package/dist/index.js +9 -2
- package/jest.config.cjs +7 -0
- package/package.json +7 -3
- package/src/App.jsx +39 -16
- package/src/components/ContainerCreationPrompt.jsx +54 -10
- package/src/components/ContainerList.jsx +3 -3
- package/src/components/ContainerRow.jsx +19 -16
- package/src/components/ContainerSection.jsx +3 -3
- package/src/components/ControlsHUD.jsx +66 -0
- package/src/components/Footer.jsx +2 -2
- package/src/components/Header.jsx +7 -7
- package/src/components/LogViewer.jsx +5 -3
- package/src/components/MessageFeedback.jsx +2 -2
- package/src/components/PromptField.jsx +4 -4
- package/src/components/StatsBar.jsx +11 -7
- package/src/components/SuggestionPanel.jsx +61 -0
- package/src/components/UsageMenu.jsx +18 -13
- package/src/helpers/appInfo.js +7 -7
- package/src/helpers/constants.js +48 -9
- package/src/helpers/containerOptionsBuilder.js +16 -5
- package/src/helpers/dockerHubService.js +75 -0
- package/src/helpers/dockerService/dockerService.js +1 -1
- package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
- package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
- package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
- package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
- package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
- package/src/helpers/exitWithMessage.js +13 -7
- package/src/helpers/imageNameUtils.js +32 -0
- package/src/helpers/logger.js +15 -16
- package/src/helpers/safeCall.js +2 -2
- package/src/helpers/validationHelpers.js +13 -29
- package/src/hooks/creation/useContainerActions.js +32 -12
- package/src/hooks/creation/useContainerCreation.js +293 -47
- package/src/hooks/creation/useLogsViewer.js +2 -2
- package/src/hooks/debug/useDebugLogs.js +15 -10
- package/src/hooks/navigation/useContainerSelection.js +17 -14
- package/src/hooks/useContainerCommandRouter.js +71 -58
- package/src/hooks/useContainers.js +4 -4
- package/src/hooks/useControls.js +135 -72
- package/src/hooks/useEraseConfirmation.js +3 -3
- package/src/hooks/useExitHandler.js +29 -26
- package/src/index.js +8 -4
- package/test/ContainerCreationPrompt.dom.test.js +313 -0
- package/test/ControlsHUD.dom.test.js +125 -0
- package/test/SuggestionPanel.dom.test.js +83 -0
- package/test/constants.test.js +41 -0
- package/test/dockerHubService.test.js +213 -0
- package/test/logger.test.js +58 -0
- package/test/resolveImageTag.test.js +26 -0
- package/test/useContainerCreation.dom.test.js +428 -2
- package/test/useContainerCreationHub.dom.test.js +184 -0
- package/test/useControls.dom.test.js +406 -4
package/src/helpers/logger.js
CHANGED
|
@@ -6,7 +6,7 @@ const LOG_LEVELS = {
|
|
|
6
6
|
error: 0,
|
|
7
7
|
warn: 1,
|
|
8
8
|
info: 2,
|
|
9
|
-
debug: 3
|
|
9
|
+
debug: 3,
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -24,7 +24,7 @@ const LOG_LEVELS = {
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
function parseLevel(value) {
|
|
27
|
-
if (typeof value !==
|
|
27
|
+
if (typeof value !== 'string') {
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
const normalized = value.trim().toLowerCase();
|
|
@@ -39,13 +39,13 @@ const envLevel =
|
|
|
39
39
|
parseLevel(process.env.LOG_LEVEL) ??
|
|
40
40
|
LOG_LEVELS.info;
|
|
41
41
|
|
|
42
|
-
const currentLevel = typeof envLevel ===
|
|
42
|
+
const currentLevel = typeof envLevel === 'number' ? envLevel : LOG_LEVELS.info;
|
|
43
43
|
|
|
44
44
|
const listeners = new Set();
|
|
45
45
|
|
|
46
46
|
function shouldLog(level) {
|
|
47
47
|
const target = LOG_LEVELS[level];
|
|
48
|
-
return typeof target ===
|
|
48
|
+
return typeof target === 'number' && target <= currentLevel;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function log(level, message, ...args) {
|
|
@@ -58,12 +58,11 @@ function log(level, message, ...args) {
|
|
|
58
58
|
const formatted = `[${timestamp}] [${upperLevel}] ${message}`;
|
|
59
59
|
const entry = { level, message, args, timestamp, formatted };
|
|
60
60
|
|
|
61
|
-
const shouldWriteToConsole =
|
|
62
|
-
listeners.size === 0 || level === "error" || level === "warn";
|
|
61
|
+
const shouldWriteToConsole = listeners.size === 0;
|
|
63
62
|
|
|
64
63
|
if (shouldWriteToConsole) {
|
|
65
|
-
const method = level ===
|
|
66
|
-
if (typeof console[method] ===
|
|
64
|
+
const method = level === 'debug' ? 'log' : level;
|
|
65
|
+
if (typeof console[method] === 'function') {
|
|
67
66
|
console[method](formatted, ...args);
|
|
68
67
|
} else {
|
|
69
68
|
console.log(formatted, ...args);
|
|
@@ -86,28 +85,28 @@ function log(level, message, ...args) {
|
|
|
86
85
|
*/
|
|
87
86
|
export const logger = {
|
|
88
87
|
debug(message, ...args) {
|
|
89
|
-
log(
|
|
88
|
+
log('debug', message, ...args);
|
|
90
89
|
},
|
|
91
90
|
info(message, ...args) {
|
|
92
|
-
log(
|
|
91
|
+
log('info', message, ...args);
|
|
93
92
|
},
|
|
94
93
|
warn(message, ...args) {
|
|
95
|
-
log(
|
|
94
|
+
log('warn', message, ...args);
|
|
96
95
|
},
|
|
97
96
|
error(message, ...args) {
|
|
98
|
-
log(
|
|
97
|
+
log('error', message, ...args);
|
|
99
98
|
},
|
|
100
99
|
/**
|
|
101
100
|
* Subscribe to log events.
|
|
102
101
|
*
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
* @param {LogListener} listener Listener invoked for every log entry that passes the current level filter.
|
|
103
|
+
* @returns {function(): void} Cleanup function that removes the listener when called.
|
|
105
104
|
*/
|
|
106
105
|
subscribe(listener) {
|
|
107
|
-
if (typeof listener !==
|
|
106
|
+
if (typeof listener !== 'function') {
|
|
108
107
|
return () => {};
|
|
109
108
|
}
|
|
110
109
|
listeners.add(listener);
|
|
111
110
|
return () => listeners.delete(listener);
|
|
112
|
-
}
|
|
111
|
+
},
|
|
113
112
|
};
|
package/src/helpers/safeCall.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logger } from
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Call a function safely: if it's a function call it with provided args and
|
|
@@ -12,7 +12,7 @@ export function safeCall(fn, ...args) {
|
|
|
12
12
|
try {
|
|
13
13
|
return fn(...args);
|
|
14
14
|
} catch (e) {
|
|
15
|
-
logger.error(
|
|
15
|
+
logger.error('safeCall caught error', e);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Reusable validations for ports and environment variables
|
|
2
|
+
import { normalizeImageName } from './imageNameUtils.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Validate a comma-separated list of port mappings in the form "host:container".
|
|
@@ -10,38 +11,18 @@
|
|
|
10
11
|
export function validatePorts(portInput) {
|
|
11
12
|
// Empty or whitespace-only input is considered valid (ports are optional)
|
|
12
13
|
if (!portInput || !portInput.trim()) return true;
|
|
13
|
-
const ports = portInput
|
|
14
|
+
const ports = portInput
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((p) => p.trim())
|
|
17
|
+
.filter(Boolean);
|
|
14
18
|
if (ports.length === 0) return true;
|
|
15
|
-
const invalid = ports.find(pair => {
|
|
16
|
-
const [host, cont] = pair.split(
|
|
19
|
+
const invalid = ports.find((pair) => {
|
|
20
|
+
const [host, cont] = pair.split(':');
|
|
17
21
|
return !host || !cont || isNaN(Number(host)) || isNaN(Number(cont));
|
|
18
22
|
});
|
|
19
23
|
return !invalid;
|
|
20
24
|
}
|
|
21
25
|
|
|
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
|
-
|
|
45
26
|
/**
|
|
46
27
|
* Validate environment variable input as comma-separated VAR=value pairs.
|
|
47
28
|
*
|
|
@@ -64,9 +45,12 @@ export function validateEnvVars(envInput, imageName, imageProfiles) {
|
|
|
64
45
|
const syntaxErrors = [];
|
|
65
46
|
|
|
66
47
|
if (envInput && envInput.trim()) {
|
|
67
|
-
const vars = envInput
|
|
48
|
+
const vars = envInput
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((v) => v.trim())
|
|
51
|
+
.filter(Boolean);
|
|
68
52
|
for (const v of vars) {
|
|
69
|
-
const eqIdx = v.indexOf(
|
|
53
|
+
const eqIdx = v.indexOf('=');
|
|
70
54
|
if (eqIdx === -1) {
|
|
71
55
|
syntaxErrors.push(`"${v}" is missing an '=' sign`);
|
|
72
56
|
continue;
|
|
@@ -93,7 +77,7 @@ export function validateEnvVars(envInput, imageName, imageProfiles) {
|
|
|
93
77
|
|
|
94
78
|
if (profile && profile.requiredEnv && profile.requiredEnv.length) {
|
|
95
79
|
for (const required of profile.requiredEnv) {
|
|
96
|
-
if (!parsedEnv[required] || parsedEnv[required].trim() ===
|
|
80
|
+
if (!parsedEnv[required] || parsedEnv[required].trim() === '') {
|
|
97
81
|
errors.push(`Missing required env var: ${required}`);
|
|
98
82
|
}
|
|
99
83
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { useState } from
|
|
2
|
-
import { safeCall } from
|
|
3
|
-
import {
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { safeCall } from '../../helpers/safeCall.js';
|
|
3
|
+
import {
|
|
4
|
+
startContainer as svcStartContainer,
|
|
5
|
+
stopContainer as svcStopContainer,
|
|
6
|
+
restartContainer as svcRestartContainer,
|
|
7
|
+
removeContainer as svcRemoveContainer,
|
|
8
|
+
} from '../../helpers/dockerService/serviceComponents/containerActions.js';
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Custom hook to manage container actions (start, stop, restart, remove).
|
|
@@ -12,8 +17,21 @@ import { startContainer as svcStartContainer, stopContainer as svcStopContainer,
|
|
|
12
17
|
* @returns {Object} Action helpers and feedback state
|
|
13
18
|
*/
|
|
14
19
|
export function useContainerActions({ containers, onAction }) {
|
|
15
|
-
const [message, setMessage] = useState(
|
|
16
|
-
const [messageColor, setMessageColor] = useState(
|
|
20
|
+
const [message, setMessage] = useState('');
|
|
21
|
+
const [messageColor, setMessageColor] = useState('yellow');
|
|
22
|
+
|
|
23
|
+
const messageTimerRef = useRef(null);
|
|
24
|
+
|
|
25
|
+
function setTimedMessage(msg, color = 'yellow', ms = 4000) {
|
|
26
|
+
clearTimeout(messageTimerRef.current);
|
|
27
|
+
setMessage(msg);
|
|
28
|
+
if (color !== undefined) setMessageColor(color);
|
|
29
|
+
if (msg) {
|
|
30
|
+
messageTimerRef.current = setTimeout(() => setMessage(''), ms);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => () => clearTimeout(messageTimerRef.current), []);
|
|
17
35
|
|
|
18
36
|
/**
|
|
19
37
|
* Handles a generic container action and sets feedback.
|
|
@@ -29,26 +47,28 @@ export function useContainerActions({ containers, onAction }) {
|
|
|
29
47
|
const checkMsg = stateCheck(container);
|
|
30
48
|
if (checkMsg) {
|
|
31
49
|
setMessage(checkMsg);
|
|
32
|
-
setMessageColor(
|
|
50
|
+
setMessageColor('yellow');
|
|
33
51
|
return;
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
setMessage(`${actionLabel} container...`);
|
|
37
|
-
setMessageColor(
|
|
55
|
+
setMessageColor('yellow');
|
|
38
56
|
try {
|
|
39
57
|
await actionFn(container.id);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
safeCall(onAction);
|
|
58
|
+
setTimedMessage(`${actionLabel} container successful.`, 'green');
|
|
59
|
+
safeCall(onAction);
|
|
43
60
|
} catch (err) {
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
setTimedMessage(
|
|
62
|
+
`Failed to ${actionLabel.toLowerCase()} container: ${err.message}`,
|
|
63
|
+
'red'
|
|
64
|
+
);
|
|
46
65
|
}
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
return {
|
|
50
69
|
message,
|
|
51
70
|
setMessage,
|
|
71
|
+
setTimedMessage,
|
|
52
72
|
messageColor,
|
|
53
73
|
setMessageColor,
|
|
54
74
|
handleAction,
|
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
import { useState } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { useReducer, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
validatePorts,
|
|
4
|
+
validateEnvVars,
|
|
5
|
+
} from '../../helpers/validationHelpers.js';
|
|
6
|
+
import { IMAGE_PROFILES, resolveImageTag } from '../../helpers/constants.js';
|
|
7
|
+
import { safeCall } from '../../helpers/safeCall.js';
|
|
8
|
+
import { searchDockerHub, formatHubResult } from '../../helpers/dockerHubService.js';
|
|
9
|
+
|
|
10
|
+
const MAX_VISIBLE = 6;
|
|
11
|
+
|
|
12
|
+
const INITIAL_FORM = {
|
|
13
|
+
step: 0,
|
|
14
|
+
imageName: '',
|
|
15
|
+
containerName: '',
|
|
16
|
+
portInput: '',
|
|
17
|
+
envInput: '',
|
|
18
|
+
message: '',
|
|
19
|
+
messageColor: 'yellow',
|
|
20
|
+
suggestions: [],
|
|
21
|
+
selectedSuggestionIndex: -1,
|
|
22
|
+
visibleOffset: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function formReducer(state, action) {
|
|
26
|
+
switch (action.type) {
|
|
27
|
+
case 'RESET': return { ...INITIAL_FORM };
|
|
28
|
+
case 'RESET_WIZARD': return { ...INITIAL_FORM, message: 'Insert the name of the image to create: ', messageColor: 'yellow' };
|
|
29
|
+
case 'SET': return { ...state, ...action.payload };
|
|
30
|
+
default: return state;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
5
33
|
|
|
6
34
|
/**
|
|
7
35
|
* Custom hook to manage the container creation flow, step by step.
|
|
@@ -14,14 +42,183 @@ import { safeCall } from "../../helpers/safeCall.js";
|
|
|
14
42
|
* @param {Object} [params.imageProfiles] - Image profiles map for contextual validation
|
|
15
43
|
* @returns {Object} Creation state, setters, and helpers
|
|
16
44
|
*/
|
|
17
|
-
export function useContainerCreation({
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const [
|
|
24
|
-
const
|
|
45
|
+
export function useContainerCreation({
|
|
46
|
+
onCreate,
|
|
47
|
+
onCancel,
|
|
48
|
+
dbImages = [],
|
|
49
|
+
imageProfiles = IMAGE_PROFILES,
|
|
50
|
+
}) {
|
|
51
|
+
const [form, dispatch] = useReducer(formReducer, INITIAL_FORM);
|
|
52
|
+
const {
|
|
53
|
+
step,
|
|
54
|
+
imageName,
|
|
55
|
+
containerName,
|
|
56
|
+
portInput,
|
|
57
|
+
envInput,
|
|
58
|
+
message,
|
|
59
|
+
messageColor,
|
|
60
|
+
suggestions,
|
|
61
|
+
selectedSuggestionIndex,
|
|
62
|
+
visibleOffset,
|
|
63
|
+
} = form;
|
|
64
|
+
|
|
65
|
+
// Convenience setters that mirror the old useState API
|
|
66
|
+
const setStep = (v) => dispatch({ type: 'SET', payload: { step: v } });
|
|
67
|
+
const setImageName = (v) => dispatch({ type: 'SET', payload: { imageName: v } });
|
|
68
|
+
const setContainerName = (v) => dispatch({ type: 'SET', payload: { containerName: v } });
|
|
69
|
+
const setPortInput = (v) => dispatch({ type: 'SET', payload: { portInput: v } });
|
|
70
|
+
const setEnvInput = (v) =>
|
|
71
|
+
dispatch({ type: 'SET', payload: { envInput: typeof v === 'function' ? v(form.envInput) : v } });
|
|
72
|
+
const setMessage = (v) => dispatch({ type: 'SET', payload: { message: v } });
|
|
73
|
+
const setMessageColor = (v) => dispatch({ type: 'SET', payload: { messageColor: v } });
|
|
74
|
+
const setSuggestions = (v) => dispatch({ type: 'SET', payload: { suggestions: v } });
|
|
75
|
+
const setSelectedSuggestionIndex = (fn) => {
|
|
76
|
+
// Support both direct value and updater function
|
|
77
|
+
if (typeof fn === 'function') {
|
|
78
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: fn(form.selectedSuggestionIndex) } });
|
|
79
|
+
} else {
|
|
80
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: fn } });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const setVisibleOffset = (fn) => {
|
|
84
|
+
if (typeof fn === 'function') {
|
|
85
|
+
dispatch({ type: 'SET', payload: { visibleOffset: fn(form.visibleOffset) } });
|
|
86
|
+
} else {
|
|
87
|
+
dispatch({ type: 'SET', payload: { visibleOffset: fn } });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Docker Hub search state (kept as useState because they are independent of form)
|
|
92
|
+
const [isSearchingHub, setIsSearchingHub] = useState(false);
|
|
93
|
+
const [hubResults, setHubResults] = useState(null);
|
|
94
|
+
// Tracks active request: { controller: AbortController|null, requestId: number }
|
|
95
|
+
const activeHubRequestRef = useRef({ controller: null, requestId: 0 });
|
|
96
|
+
|
|
97
|
+
// Timer for auto-clearing messages
|
|
98
|
+
const messageTimerRef = useRef(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Sets a permanent message (e.g. step instructions). No auto-clear.
|
|
102
|
+
*/
|
|
103
|
+
function setStepMessage(msg, color = 'yellow') {
|
|
104
|
+
clearTimeout(messageTimerRef.current);
|
|
105
|
+
dispatch({ type: 'SET', payload: { message: msg, messageColor: color } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sets a transient feedback message (e.g. validation errors). Auto-clears after `ms`.
|
|
110
|
+
*/
|
|
111
|
+
function setTimedMessage(msg, color = 'yellow', ms = 4000) {
|
|
112
|
+
clearTimeout(messageTimerRef.current);
|
|
113
|
+
dispatch({ type: 'SET', payload: { message: msg, messageColor: color } });
|
|
114
|
+
if (msg) {
|
|
115
|
+
messageTimerRef.current = setTimeout(() => dispatch({ type: 'SET', payload: { message: '' } }), ms);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
useEffect(() => () => clearTimeout(messageTimerRef.current), []);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Updates the image name input and recalculates autocomplete suggestions.
|
|
123
|
+
* Resets selectedSuggestionIndex to -1 on every keystroke.
|
|
124
|
+
* Also aborts any in-flight Hub search and clears Hub results.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} value - New raw image name typed by user
|
|
127
|
+
*/
|
|
128
|
+
function updateImageInput(value) {
|
|
129
|
+
// Abort any in-flight Hub search and clear its state
|
|
130
|
+
activeHubRequestRef.current.controller?.abort();
|
|
131
|
+
activeHubRequestRef.current.requestId += 1;
|
|
132
|
+
setIsSearchingHub(false);
|
|
133
|
+
setHubResults(null);
|
|
134
|
+
|
|
135
|
+
const lower = value.toLowerCase();
|
|
136
|
+
const matches = value.trim()
|
|
137
|
+
? Object.keys(imageProfiles).filter((key) => key.includes(lower))
|
|
138
|
+
: [];
|
|
139
|
+
|
|
140
|
+
dispatch({
|
|
141
|
+
type: 'SET',
|
|
142
|
+
payload: {
|
|
143
|
+
imageName: value,
|
|
144
|
+
selectedSuggestionIndex: -1,
|
|
145
|
+
visibleOffset: 0,
|
|
146
|
+
suggestions: matches,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Triggers a Docker Hub search for the current imageName.
|
|
153
|
+
* Guards: imageName must be non-empty; no concurrent search allowed.
|
|
154
|
+
* Uses AbortController + requestId to handle race conditions.
|
|
155
|
+
*/
|
|
156
|
+
async function triggerHubSearch() {
|
|
157
|
+
const query = imageName.trim();
|
|
158
|
+
if (!query) return;
|
|
159
|
+
if (isSearchingHub) return;
|
|
160
|
+
|
|
161
|
+
// Abort previous request (should already be aborted from updateImageInput,
|
|
162
|
+
// but guard here too)
|
|
163
|
+
activeHubRequestRef.current.controller?.abort();
|
|
164
|
+
|
|
165
|
+
const requestId = activeHubRequestRef.current.requestId + 1;
|
|
166
|
+
activeHubRequestRef.current.requestId = requestId;
|
|
167
|
+
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
activeHubRequestRef.current.controller = controller;
|
|
170
|
+
|
|
171
|
+
setIsSearchingHub(true);
|
|
172
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: -1, visibleOffset: 0 } });
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const results = await searchDockerHub(query, { signal: controller.signal });
|
|
176
|
+
// Ignore stale responses
|
|
177
|
+
if (activeHubRequestRef.current.requestId !== requestId) return;
|
|
178
|
+
setHubResults(results.map(formatHubResult));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (activeHubRequestRef.current.requestId !== requestId) return;
|
|
181
|
+
// Silence AbortError; for all other errors fall back to static suggestions
|
|
182
|
+
setHubResults(null);
|
|
183
|
+
} finally {
|
|
184
|
+
if (activeHubRequestRef.current.requestId === requestId) {
|
|
185
|
+
setIsSearchingHub(false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Moves the suggestion selection up (-1) or down (+1).
|
|
192
|
+
* Clamps index to [-1, suggestions.length - 1].
|
|
193
|
+
* Adjusts visibleOffset to keep selected item in the visible window.
|
|
194
|
+
*
|
|
195
|
+
* @param {number} direction - -1 (up) or 1 (down)
|
|
196
|
+
*/
|
|
197
|
+
function moveSuggestionSelection(direction) {
|
|
198
|
+
const next = Math.max(-1, Math.min(suggestions.length - 1, selectedSuggestionIndex + direction));
|
|
199
|
+
let newOffset = visibleOffset;
|
|
200
|
+
if (next < visibleOffset) newOffset = Math.max(0, next);
|
|
201
|
+
else if (next >= visibleOffset + MAX_VISIBLE) newOffset = next - MAX_VISIBLE + 1;
|
|
202
|
+
dispatch({ type: 'SET', payload: { selectedSuggestionIndex: next, visibleOffset: newOffset } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Applies the currently focused suggestion to imageName.
|
|
207
|
+
* Does NOT advance the step. Clears suggestions after applying.
|
|
208
|
+
*/
|
|
209
|
+
function applyFocusedSuggestion() {
|
|
210
|
+
if (selectedSuggestionIndex < 0 || selectedSuggestionIndex >= suggestions.length) return;
|
|
211
|
+
const chosen = suggestions[selectedSuggestionIndex];
|
|
212
|
+
dispatch({
|
|
213
|
+
type: 'SET',
|
|
214
|
+
payload: {
|
|
215
|
+
imageName: resolveImageTag(chosen, imageProfiles),
|
|
216
|
+
suggestions: [],
|
|
217
|
+
selectedSuggestionIndex: -1,
|
|
218
|
+
visibleOffset: 0,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
25
222
|
|
|
26
223
|
/**
|
|
27
224
|
* Advances to the next step, with validation and feedback.
|
|
@@ -29,42 +226,70 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
|
|
|
29
226
|
function nextStep() {
|
|
30
227
|
if (step === 0) {
|
|
31
228
|
if (!imageName.trim()) {
|
|
32
|
-
|
|
33
|
-
setMessageColor("red");
|
|
229
|
+
setTimedMessage('Image name cannot be empty.', 'red');
|
|
34
230
|
return;
|
|
35
231
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
232
|
+
const resolved = resolveImageTag(imageName, imageProfiles);
|
|
233
|
+
dispatch({ type: 'SET', payload: { imageName: resolved, step: 1 } });
|
|
234
|
+
setStepMessage(
|
|
235
|
+
'Optional: Enter container name or leave empty and press Enter',
|
|
236
|
+
'yellow'
|
|
237
|
+
);
|
|
39
238
|
return;
|
|
40
239
|
}
|
|
41
240
|
if (step === 1) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
241
|
+
dispatch({ type: 'SET', payload: { step: 2 } });
|
|
242
|
+
setStepMessage(
|
|
243
|
+
'Optional: Enter ports (format 8080:80,443:443) or leave empty and press Enter',
|
|
244
|
+
'yellow'
|
|
245
|
+
);
|
|
45
246
|
return;
|
|
46
247
|
}
|
|
47
248
|
if (step === 2) {
|
|
48
249
|
// Ports are now optional - only validate if provided
|
|
49
250
|
if (portInput.trim() && !validatePorts(portInput)) {
|
|
50
|
-
|
|
51
|
-
|
|
251
|
+
setTimedMessage(
|
|
252
|
+
'Port format must be host:container and both must be numbers (e.g. 8080:80)',
|
|
253
|
+
'red'
|
|
254
|
+
);
|
|
52
255
|
return;
|
|
53
256
|
}
|
|
54
|
-
|
|
55
|
-
const isDb = dbImages.some(db =>
|
|
257
|
+
dispatch({ type: 'SET', payload: { step: 3 } });
|
|
258
|
+
const isDb = dbImages.some((db) =>
|
|
259
|
+
imageName.trim().toLowerCase().includes(db)
|
|
260
|
+
);
|
|
56
261
|
// Check if the image has a profile with required env vars
|
|
57
|
-
const baseName = imageName
|
|
262
|
+
const baseName = imageName
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.split(':')[0]
|
|
266
|
+
.split('/')
|
|
267
|
+
.pop();
|
|
58
268
|
const profile = imageProfiles[baseName];
|
|
59
269
|
if (profile && profile.requiredEnv && profile.requiredEnv.length) {
|
|
60
|
-
|
|
61
|
-
|
|
270
|
+
const suggestedPart =
|
|
271
|
+
profile.suggestedEnv && profile.suggestedEnv.length
|
|
272
|
+
? ` | Suggested: ${profile.suggestedEnv.join(', ')}`
|
|
273
|
+
: '';
|
|
274
|
+
setStepMessage(
|
|
275
|
+
`Required env vars for ${baseName}: ${profile.requiredEnv.join(', ')}. Enter as VAR=val,VAR2=val2${suggestedPart}`,
|
|
276
|
+
'yellow'
|
|
277
|
+
);
|
|
278
|
+
} else if (profile && profile.suggestedEnv && profile.suggestedEnv.length) {
|
|
279
|
+
setStepMessage(
|
|
280
|
+
`Suggested env vars for ${baseName}: ${profile.suggestedEnv.join(', ')}. Enter as VAR=val,VAR2=val2 or leave empty.`,
|
|
281
|
+
'yellow'
|
|
282
|
+
);
|
|
62
283
|
} else if (isDb) {
|
|
63
|
-
|
|
64
|
-
|
|
284
|
+
setStepMessage(
|
|
285
|
+
'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.',
|
|
286
|
+
'yellow'
|
|
287
|
+
);
|
|
65
288
|
} else {
|
|
66
|
-
|
|
67
|
-
|
|
289
|
+
setStepMessage(
|
|
290
|
+
'Optional: Enter environment variables (format VAR1=val1,VAR2=val2) or leave empty and press Enter',
|
|
291
|
+
'yellow'
|
|
292
|
+
);
|
|
68
293
|
}
|
|
69
294
|
return;
|
|
70
295
|
}
|
|
@@ -72,8 +297,7 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
|
|
|
72
297
|
// Contextual env validation using image profiles
|
|
73
298
|
const result = validateEnvVars(envInput, imageName, imageProfiles);
|
|
74
299
|
if (!result.valid) {
|
|
75
|
-
|
|
76
|
-
setMessageColor("red");
|
|
300
|
+
setTimedMessage(result.errors.join(' | '), 'red');
|
|
77
301
|
return;
|
|
78
302
|
}
|
|
79
303
|
// Final step: call onCreate with all data (safely)
|
|
@@ -85,13 +309,7 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
|
|
|
85
309
|
* Cancels the creation process and resets state.
|
|
86
310
|
*/
|
|
87
311
|
function cancelCreation() {
|
|
88
|
-
|
|
89
|
-
setImageName("");
|
|
90
|
-
setContainerName("");
|
|
91
|
-
setPortInput("");
|
|
92
|
-
setEnvInput("");
|
|
93
|
-
setMessage("");
|
|
94
|
-
setMessageColor("yellow");
|
|
312
|
+
dispatch({ type: 'RESET' });
|
|
95
313
|
safeCall(onCancel);
|
|
96
314
|
}
|
|
97
315
|
|
|
@@ -100,15 +318,32 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
|
|
|
100
318
|
* Used by the command router when the user presses 'c' to open the creation wizard.
|
|
101
319
|
*/
|
|
102
320
|
function resetCreation() {
|
|
103
|
-
|
|
104
|
-
setImageName("");
|
|
105
|
-
setContainerName("");
|
|
106
|
-
setPortInput("");
|
|
107
|
-
setEnvInput("");
|
|
108
|
-
setMessage("Insert the name of the image to create: ");
|
|
109
|
-
setMessageColor("yellow");
|
|
321
|
+
dispatch({ type: 'RESET_WIZARD' });
|
|
110
322
|
}
|
|
111
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Inserts the next pending suggested env var into envInput.
|
|
326
|
+
* Derives "next" by comparing keys already in envInput against suggestedEnv.
|
|
327
|
+
* When all suggestions are already present, sets a feedback message.
|
|
328
|
+
*/
|
|
329
|
+
function insertNextSuggestedEnv() {
|
|
330
|
+
const baseName = imageName.trim().toLowerCase().split(':')[0].split('/').pop();
|
|
331
|
+
const profile = imageProfiles[baseName];
|
|
332
|
+
const suggested = profile?.suggestedEnv ?? [];
|
|
333
|
+
if (!suggested.length) return;
|
|
334
|
+
const addedKeys = envInput.split(',').map(s => s.split('=')[0].trim()).filter(Boolean);
|
|
335
|
+
const next = suggested.find(s => !addedKeys.includes(s.split('=')[0]));
|
|
336
|
+
if (!next) { setTimedMessage('All suggested env vars added', 'yellow'); return; }
|
|
337
|
+
const newEnvInput = envInput ? `${envInput},${next}` : next;
|
|
338
|
+
dispatch({ type: 'SET', payload: { envInput: newEnvInput } });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* True when the current image profile has at least one suggestedEnv entry.
|
|
343
|
+
*/
|
|
344
|
+
const baseName = imageName.trim().toLowerCase().split(':')[0].split('/').pop();
|
|
345
|
+
const hasSuggestedEnv = (imageProfiles[baseName]?.suggestedEnv?.length ?? 0) > 0;
|
|
346
|
+
|
|
112
347
|
return {
|
|
113
348
|
step,
|
|
114
349
|
setStep,
|
|
@@ -124,8 +359,19 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageP
|
|
|
124
359
|
setMessage,
|
|
125
360
|
messageColor,
|
|
126
361
|
setMessageColor,
|
|
362
|
+
suggestions,
|
|
363
|
+
selectedSuggestionIndex,
|
|
364
|
+
visibleOffset,
|
|
365
|
+
isSearchingHub,
|
|
366
|
+
hubResults,
|
|
367
|
+
updateImageInput,
|
|
368
|
+
triggerHubSearch,
|
|
369
|
+
moveSuggestionSelection,
|
|
370
|
+
applyFocusedSuggestion,
|
|
127
371
|
nextStep,
|
|
128
372
|
cancelCreation,
|
|
129
373
|
resetCreation,
|
|
374
|
+
insertNextSuggestedEnv,
|
|
375
|
+
hasSuggestedEnv,
|
|
130
376
|
};
|
|
131
377
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useRef } from
|
|
2
|
-
import { safeCall } from
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
2
|
+
import { safeCall } from '../../helpers/safeCall.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Custom hook to manage the logs viewer state and stream reference.
|