cdd-cli 3.2.2 → 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.
Files changed (74) hide show
  1. package/.~lock.ROADMAP.md# +1 -0
  2. package/CHANGELOG.md +51 -0
  3. package/README.es.md +198 -0
  4. package/README.md +81 -23
  5. package/ROADMAP.md +647 -0
  6. package/coverage/clover.xml +400 -519
  7. package/coverage/coverage-final.json +16 -20
  8. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  9. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  10. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +31 -13
  11. package/coverage/lcov-report/components/PromptField.jsx.html +4 -4
  12. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +37 -13
  13. package/coverage/lcov-report/components/index.html +14 -14
  14. package/coverage/lcov-report/helpers/constants.js.html +87 -33
  15. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +40 -40
  16. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +1 -1
  18. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +1 -1
  19. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +1 -1
  20. package/coverage/lcov-report/helpers/imageNameUtils.js.html +18 -18
  21. package/coverage/lcov-report/helpers/index.html +77 -62
  22. package/coverage/lcov-report/helpers/logger.js.html +60 -60
  23. package/coverage/lcov-report/helpers/safeCall.js.html +14 -14
  24. package/coverage/lcov-report/helpers/validationHelpers.js.html +52 -52
  25. package/coverage/lcov-report/hooks/creation/index.html +19 -19
  26. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +4 -4
  27. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +225 -48
  28. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +5 -5
  29. package/coverage/lcov-report/hooks/debug/index.html +1 -1
  30. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +7 -7
  31. package/coverage/lcov-report/hooks/index.html +15 -15
  32. package/coverage/lcov-report/hooks/navigation/index.html +1 -1
  33. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +6 -6
  34. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +13 -13
  35. package/coverage/lcov-report/hooks/useControls.js.html +82 -40
  36. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +5 -5
  37. package/coverage/lcov-report/hooks/useExitHandler.js.html +6 -6
  38. package/coverage/lcov-report/index.html +38 -68
  39. package/coverage/lcov.info +742 -981
  40. package/dist/App.js +7 -1
  41. package/dist/components/ContainerCreationPrompt.js +26 -10
  42. package/dist/components/ControlsHUD.js +106 -0
  43. package/dist/components/SuggestionPanel.js +16 -7
  44. package/dist/helpers/constants.js +60 -21
  45. package/dist/helpers/dockerHubService.js +111 -0
  46. package/dist/helpers/logger.js +1 -1
  47. package/dist/hooks/creation/useContainerActions.js +22 -5
  48. package/dist/hooks/creation/useContainerCreation.js +395 -107
  49. package/dist/hooks/useContainerCommandRouter.js +1 -1
  50. package/dist/hooks/useControls.js +23 -7
  51. package/dist/index.js +7 -0
  52. package/package.json +1 -1
  53. package/src/App.jsx +6 -0
  54. package/src/components/ContainerCreationPrompt.jsx +15 -5
  55. package/src/components/ControlsHUD.jsx +66 -0
  56. package/src/components/SuggestionPanel.jsx +10 -2
  57. package/src/helpers/constants.js +38 -20
  58. package/src/helpers/dockerHubService.js +75 -0
  59. package/src/helpers/logger.js +1 -2
  60. package/src/hooks/creation/useContainerActions.js +19 -6
  61. package/src/hooks/creation/useContainerCreation.js +224 -87
  62. package/src/hooks/useContainerCommandRouter.js +1 -1
  63. package/src/hooks/useControls.js +24 -8
  64. package/src/index.js +4 -0
  65. package/test/ContainerCreationPrompt.dom.test.js +245 -0
  66. package/test/ControlsHUD.dom.test.js +125 -0
  67. package/test/SuggestionPanel.dom.test.js +30 -0
  68. package/test/constants.test.js +7 -0
  69. package/test/dockerHubService.test.js +213 -0
  70. package/test/logger.test.js +58 -0
  71. package/test/resolveImageTag.test.js +26 -0
  72. package/test/useContainerCreation.dom.test.js +306 -3
  73. package/test/useContainerCreationHub.dom.test.js +184 -0
  74. package/test/useControls.dom.test.js +212 -6
@@ -36,7 +36,9 @@ import { DB_IMAGES } from '../helpers/constants.js';
36
36
  * @returns {Object} controls - API for the App component
37
37
  */
38
38
  export function useControls() {
39
+ var _overrides$triggerHub, _overrides$isSearchin;
39
40
  var containers = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
41
+ var overrides = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
40
42
  var _React$useState = React.useState(false),
41
43
  _React$useState2 = _slicedToArray(_React$useState, 2),
42
44
  creatingContainer = _React$useState2[0],
@@ -60,8 +62,7 @@ export function useControls() {
60
62
  portInput: portInput,
61
63
  envInput: envInput
62
64
  });
63
- actions.setMessage("Creating container ".concat(imageName, "..."));
64
- actions.setMessageColor('yellow');
65
+ actions.setTimedMessage("Creating container ".concat(imageName, "..."), 'yellow');
65
66
  _context.p = 1;
66
67
  _context.n = 2;
67
68
  return svcCreateContainer(imageName, options);
@@ -75,15 +76,13 @@ export function useControls() {
75
76
  return "".concat(p.hostPort, "\u2192").concat(p.containerPort, "/").concat(p.protocol);
76
77
  }).join(', ');
77
78
  }
78
- actions.setMessage("Created container ".concat(id).concat(portMsg));
79
- actions.setMessageColor('green');
79
+ actions.setTimedMessage("Created container ".concat(id).concat(portMsg), 'green');
80
80
  _context.n = 4;
81
81
  break;
82
82
  case 3:
83
83
  _context.p = 3;
84
84
  _t = _context.v;
85
- actions.setMessage("Error creating container: ".concat(_t.message));
86
- actions.setMessageColor('red');
85
+ actions.setTimedMessage("Error creating container: ".concat(_t.message), 'red');
87
86
  case 4:
88
87
  _context.p = 4;
89
88
  setCreatingContainer(false);
@@ -106,6 +105,11 @@ export function useControls() {
106
105
  var logsViewer = useLogsViewer();
107
106
  var selection = useContainerSelection(containers.length);
108
107
  var debugLogs = useDebugLogs();
108
+
109
+ // Allow overrides for testability (e.g. injecting a mock triggerHubSearch)
110
+ var triggerHubSearch = (_overrides$triggerHub = overrides.triggerHubSearch) !== null && _overrides$triggerHub !== void 0 ? _overrides$triggerHub : creation.triggerHubSearch;
111
+ var isSearchingHub = (_overrides$isSearchin = overrides.isSearchingHub) !== null && _overrides$isSearchin !== void 0 ? _overrides$isSearchin : creation.isSearchingHub;
112
+ var insertNextSuggestedEnv = overrides.insertNextSuggestedEnv !== undefined ? overrides.insertNextSuggestedEnv : creation.insertNextSuggestedEnv;
109
113
  var eraseConfirmation = useEraseConfirmation({
110
114
  onConfirm: function onConfirm() {
111
115
  actions.handleAction({
@@ -211,6 +215,18 @@ export function useControls() {
211
215
  return;
212
216
  }
213
217
 
218
+ // Tab on step 0: trigger Docker Hub search (with guards)
219
+ if (key.tab) {
220
+ if (step === 0) {
221
+ if (!isSearchingHub && (creation.imageName || '').trim() !== '') {
222
+ triggerHubSearch();
223
+ }
224
+ } else if (step === 3) {
225
+ insertNextSuggestedEnv === null || insertNextSuggestedEnv === void 0 || insertNextSuggestedEnv();
226
+ }
227
+ return;
228
+ }
229
+
214
230
  // Arrow keys on step 0: navigate suggestion list
215
231
  if (step === 0 && creation.suggestions.length > 0) {
216
232
  if (key.upArrow) {
@@ -240,7 +256,7 @@ export function useControls() {
240
256
  if (step === 2) appendChar(creation.setPortInput, creation.portInput, input);
241
257
  if (step === 3) appendChar(creation.setEnvInput, creation.envInput, input);
242
258
  }
243
- }, [creation, setCreatingContainer]);
259
+ }, [creation, setCreatingContainer, triggerHubSearch, isSearchingHub, insertNextSuggestedEnv]);
244
260
 
245
261
  // Single keyboard entry point
246
262
  useInput(function (input, key) {
package/dist/index.js CHANGED
@@ -10,5 +10,12 @@
10
10
  import React from 'react';
11
11
  import { render } from 'ink';
12
12
  import App from './App.js';
13
+ import { logger } from './helpers/logger.js';
14
+ process.on('uncaughtException', function (err) {
15
+ return logger.error('Uncaught exception', err);
16
+ });
17
+ process.on('unhandledRejection', function (reason) {
18
+ return logger.error('Unhandled rejection', reason);
19
+ });
13
20
  console.clear();
14
21
  render(/*#__PURE__*/React.createElement(App, null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdd-cli",
3
- "version": "3.2.2",
3
+ "version": "4.0.0",
4
4
  "description": "CLI Docker Dashboard",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/App.jsx CHANGED
@@ -35,6 +35,12 @@ export default function App() {
35
35
  envInput={controls.envInput}
36
36
  message={controls.message}
37
37
  messageColor={controls.messageColor}
38
+ suggestions={controls.creation.suggestions}
39
+ selectedSuggestionIndex={controls.creation.selectedSuggestionIndex}
40
+ visibleOffset={controls.creation.visibleOffset}
41
+ isSearchingHub={controls.creation.isSearchingHub}
42
+ hubResults={controls.creation.hubResults}
43
+ hasSuggestedEnv={controls.creation.hasSuggestedEnv}
38
44
  />
39
45
  );
40
46
  }
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
- import { Box, Text } from 'ink';
2
+ import { Box } from 'ink';
3
3
  import { PromptField, PromptMessage } from './PromptField.jsx';
4
4
  import { SuggestionPanel } from './SuggestionPanel.jsx';
5
+ import { ControlsHUD } from './ControlsHUD.jsx';
5
6
  import PropTypes from 'prop-types';
6
7
 
7
8
  /**
@@ -34,10 +35,13 @@ export default function ContainerCreationPrompt(props) {
34
35
  suggestions = [],
35
36
  selectedSuggestionIndex = -1,
36
37
  visibleOffset = 0,
38
+ hubResults = null,
39
+ isSearchingHub = false,
40
+ hasSuggestedEnv = false,
37
41
  } = props;
38
42
  const prompts = [
39
43
  {
40
- label: 'Name of the image to create (e.g., nginx:latest):',
44
+ label: 'Name of the image to create (e.g., nginx:1.27-alpine):',
41
45
  value: imageName,
42
46
  required: true,
43
47
  },
@@ -58,7 +62,9 @@ export default function ContainerCreationPrompt(props) {
58
62
  },
59
63
  ];
60
64
  const { label, value, required } = prompts[step] || {};
61
- const showSuggestions = step === 0 && suggestions.length > 0;
65
+ const activeItems = hubResults ?? suggestions;
66
+ const showSuggestions = step === 0 && (isSearchingHub || activeItems.length > 0);
67
+ const hasSuggestions = (suggestions?.length > 0) || (hubResults?.length > 0);
62
68
  return (
63
69
  <Box
64
70
  flexDirection="column"
@@ -69,13 +75,14 @@ export default function ContainerCreationPrompt(props) {
69
75
  <PromptField label={label} value={value} required={required} />
70
76
  {showSuggestions && (
71
77
  <SuggestionPanel
72
- items={suggestions}
78
+ items={activeItems}
73
79
  selectedIndex={selectedSuggestionIndex}
74
80
  visibleOffset={visibleOffset}
81
+ isLoading={isSearchingHub}
75
82
  />
76
83
  )}
77
84
  <PromptMessage message={message} color={messageColor} />
78
- <Text dimColor>Press Enter to continue, Escape to cancel</Text>
85
+ <ControlsHUD step={step} hasSuggestions={hasSuggestions} isSearchingHub={isSearchingHub} hasSuggestedEnv={hasSuggestedEnv} />
79
86
  </Box>
80
87
  );
81
88
  }
@@ -91,6 +98,9 @@ ContainerCreationPrompt.propTypes = {
91
98
  suggestions: PropTypes.arrayOf(PropTypes.string),
92
99
  selectedSuggestionIndex: PropTypes.number,
93
100
  visibleOffset: PropTypes.number,
101
+ hubResults: PropTypes.arrayOf(PropTypes.string),
102
+ isSearchingHub: PropTypes.bool,
103
+ hasSuggestedEnv: PropTypes.bool,
94
104
  };
95
105
 
96
106
  // Named export for test compatibility with jest ESM interop
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ /**
5
+ * Returns the contextual keyboard hints for the current wizard state.
6
+ *
7
+ * @param {number} step - Current wizard step (0-3)
8
+ * @param {boolean} hasSuggestions - Whether suggestions/hub results are visible
9
+ * @param {boolean} isSearchingHub - Whether a Hub search is in progress
10
+ * @param {boolean} [hasSuggestedEnv=false] - Whether the image has suggestedEnv entries
11
+ * @returns {{ key: string, label: string }[]}
12
+ */
13
+ function getHints(step, hasSuggestions, isSearchingHub, hasSuggestedEnv = false) {
14
+ if (step === 0) {
15
+ if (hasSuggestions) {
16
+ return [
17
+ { key: '↑↓', label: 'Navigate' },
18
+ { key: 'Enter', label: 'Select' },
19
+ { key: 'Esc', label: 'Cancel' },
20
+ ];
21
+ }
22
+ const hints = [];
23
+ if (!isSearchingHub) hints.push({ key: 'Tab', label: 'Search Hub' });
24
+ hints.push({ key: '↑↓', label: 'Browse' });
25
+ hints.push({ key: 'Enter', label: 'Confirm' });
26
+ hints.push({ key: 'Esc', label: 'Cancel' });
27
+ return hints;
28
+ }
29
+ if (step === 3) {
30
+ const hints = [];
31
+ if (hasSuggestedEnv) hints.push({ key: 'Tab', label: 'Insert next env' });
32
+ hints.push({ key: 'Enter', label: 'Continue' });
33
+ hints.push({ key: 'Esc', label: 'Cancel' });
34
+ return hints;
35
+ }
36
+ return [
37
+ { key: 'Enter', label: 'Continue' },
38
+ { key: 'Esc', label: 'Cancel' },
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * Displays a row of contextual keyboard shortcut hints for the active wizard step.
44
+ *
45
+ * @param {Object} props
46
+ * @param {number} props.step - Current wizard step
47
+ * @param {boolean} [props.hasSuggestions=false] - Whether suggestions/hub results are visible
48
+ * @param {boolean} [props.isSearchingHub=false] - Whether a Hub search is in progress
49
+ * @param {boolean} [props.hasSuggestedEnv=false] - Whether the image has suggestedEnv entries
50
+ */
51
+ function ControlsHUD({ step, hasSuggestions = false, isSearchingHub = false, hasSuggestedEnv = false }) {
52
+ const hints = getHints(step, hasSuggestions, isSearchingHub, hasSuggestedEnv);
53
+ return (
54
+ <Box flexDirection="row" columnGap={2}>
55
+ {hints.map(({ key, label }) => (
56
+ <Box key={key}>
57
+ <Text color="cyan">[{key}]</Text>
58
+ <Text dimColor> {label}</Text>
59
+ </Box>
60
+ ))}
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ export default ControlsHUD;
66
+ export { getHints, ControlsHUD };
@@ -19,10 +19,12 @@ export function SuggestionPanel({
19
19
  selectedIndex,
20
20
  visibleOffset,
21
21
  maxVisible = 6,
22
+ isLoading = false,
22
23
  }) {
23
- if (!items || items.length === 0) return null;
24
+ const hasItems = items && items.length > 0;
25
+ if (!hasItems && !isLoading) return null;
24
26
 
25
- const visible = items.slice(visibleOffset, visibleOffset + maxVisible);
27
+ const visible = hasItems ? items.slice(visibleOffset, visibleOffset + maxVisible) : [];
26
28
 
27
29
  return (
28
30
  <Box
@@ -41,6 +43,11 @@ export function SuggestionPanel({
41
43
  </Text>
42
44
  );
43
45
  })}
46
+ {isLoading && (
47
+ <Text dimColor color="gray">
48
+ {' [searching Docker Hub...]'}
49
+ </Text>
50
+ )}
44
51
  </Box>
45
52
  );
46
53
  }
@@ -50,4 +57,5 @@ SuggestionPanel.propTypes = {
50
57
  selectedIndex: PropTypes.number.isRequired,
51
58
  visibleOffset: PropTypes.number.isRequired,
52
59
  maxVisible: PropTypes.number,
60
+ isLoading: PropTypes.bool,
53
61
  };
@@ -48,24 +48,42 @@ export const DB_IMAGES = [
48
48
  * @type {Record<string, { requiredEnv: string[], defaultPort: string, suggestedEnv: string[] }>}
49
49
  */
50
50
  export const IMAGE_PROFILES = {
51
- nginx: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
52
- httpd: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
53
- caddy: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
54
- node: { requiredEnv: [], defaultPort: '3000', suggestedEnv: ['NODE_ENV=production'] },
55
- python: { requiredEnv: [], defaultPort: '8000', suggestedEnv: ['PYTHONUNBUFFERED=1'] },
56
- openjdk: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['JAVA_OPTS=-Xms256m -Xmx512m'] },
57
- golang: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['GOMAXPROCS=2'] },
58
- redis: { requiredEnv: [], defaultPort: '6379', suggestedEnv: ['REDIS_PASSWORD=change-me'] },
59
- memcached: { requiredEnv: [], defaultPort: '11211', suggestedEnv: [] },
60
- postgres: { requiredEnv: ['POSTGRES_PASSWORD'], defaultPort: '5432', suggestedEnv: ['POSTGRES_USER=postgres', 'POSTGRES_DB=app'] },
61
- mysql: { requiredEnv: ['MYSQL_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MYSQL_DATABASE=app', 'MYSQL_USER=app', 'MYSQL_PASSWORD=app123'] },
62
- mariadb: { requiredEnv: ['MARIADB_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MARIADB_DATABASE=app', 'MARIADB_USER=app', 'MARIADB_PASSWORD=app123'] },
63
- mongo: { requiredEnv: [], defaultPort: '27017', suggestedEnv: ['MONGO_INITDB_ROOT_USERNAME=admin', 'MONGO_INITDB_ROOT_PASSWORD=secret'] },
64
- mssql: { requiredEnv: ['ACCEPT_EULA', 'SA_PASSWORD'], defaultPort: '1433', suggestedEnv: ['ACCEPT_EULA=Y', 'MSSQL_PID=Developer'] },
65
- rabbitmq: { requiredEnv: [], defaultPort: '5672', suggestedEnv: ['RABBITMQ_DEFAULT_USER=guest', 'RABBITMQ_DEFAULT_PASS=guest'] },
66
- kafka: { requiredEnv: [], defaultPort: '9092', suggestedEnv: ['KAFKA_CFG_LISTENERS=PLAINTEXT://:9092', 'KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092'] },
67
- zookeeper: { requiredEnv: [], defaultPort: '2181', suggestedEnv: ['ALLOW_ANONYMOUS_LOGIN=yes'] },
68
- elasticsearch: { requiredEnv: [], defaultPort: '9200', suggestedEnv: ['discovery.type=single-node', 'xpack.security.enabled=false'] },
69
- minio: { requiredEnv: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'], defaultPort: '9000', suggestedEnv: ['MINIO_CONSOLE_ADDRESS=:9001'] },
70
- wordpress: { requiredEnv: ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'], defaultPort: '8080', suggestedEnv: ['WORDPRESS_TABLE_PREFIX=wp_'] },
51
+ nginx: { requiredEnv: [], defaultPort: '80', suggestedEnv: [], defaultTag: '1.27-alpine' },
52
+ httpd: { requiredEnv: [], defaultPort: '80', suggestedEnv: [], defaultTag: '2.4-alpine' },
53
+ caddy: { requiredEnv: [], defaultPort: '80', suggestedEnv: [], defaultTag: '2-alpine' },
54
+ node: { requiredEnv: [], defaultPort: '3000', suggestedEnv: ['NODE_ENV=production'], defaultTag: '22-alpine' },
55
+ python: { requiredEnv: [], defaultPort: '8000', suggestedEnv: ['PYTHONUNBUFFERED=1'], defaultTag: '3.12-slim' },
56
+ openjdk: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['JAVA_OPTS=-Xms256m -Xmx512m'], defaultTag: '21-slim' },
57
+ golang: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['GOMAXPROCS=2'], defaultTag: '1.22-alpine' },
58
+ redis: { requiredEnv: [], defaultPort: '6379', suggestedEnv: ['REDIS_PASSWORD=change-me'], defaultTag: '7-alpine' },
59
+ memcached: { requiredEnv: [], defaultPort: '11211', suggestedEnv: [], defaultTag: '1.6-alpine' },
60
+ postgres: { requiredEnv: ['POSTGRES_PASSWORD'], defaultPort: '5432', suggestedEnv: ['POSTGRES_PASSWORD=secret', 'POSTGRES_USER=postgres', 'POSTGRES_DB=app'], defaultTag: '17-alpine' },
61
+ mysql: { requiredEnv: ['MYSQL_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MYSQL_ROOT_PASSWORD=secret', 'MYSQL_DATABASE=app', 'MYSQL_USER=app', 'MYSQL_PASSWORD=app123'], defaultTag: '8.0' },
62
+ mariadb: { requiredEnv: ['MARIADB_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MARIADB_ROOT_PASSWORD=secret', 'MARIADB_DATABASE=app', 'MARIADB_USER=app', 'MARIADB_PASSWORD=app123'], defaultTag: '11-alpine' },
63
+ mongo: { requiredEnv: [], defaultPort: '27017', suggestedEnv: ['MONGO_INITDB_ROOT_USERNAME=admin', 'MONGO_INITDB_ROOT_PASSWORD=secret'], defaultTag: '7.0' },
64
+ mssql: { requiredEnv: ['ACCEPT_EULA', 'SA_PASSWORD'], defaultPort: '1433', suggestedEnv: ['ACCEPT_EULA=Y', 'SA_PASSWORD=Strong!Passw0rd', 'MSSQL_PID=Developer'], defaultTag: '2022-latest' },
65
+ rabbitmq: { requiredEnv: [], defaultPort: '5672', suggestedEnv: ['RABBITMQ_DEFAULT_USER=guest', 'RABBITMQ_DEFAULT_PASS=guest'], defaultTag: '3-management-alpine' },
66
+ kafka: { requiredEnv: [], defaultPort: '9092', suggestedEnv: ['KAFKA_CFG_LISTENERS=PLAINTEXT://:9092', 'KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092'], defaultTag: '3.7' },
67
+ zookeeper: { requiredEnv: [], defaultPort: '2181', suggestedEnv: ['ALLOW_ANONYMOUS_LOGIN=yes'], defaultTag: '3.9' },
68
+ elasticsearch: { requiredEnv: [], defaultPort: '9200', suggestedEnv: ['discovery.type=single-node', 'xpack.security.enabled=false'], defaultTag: '8.13.0' },
69
+ minio: { requiredEnv: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'], defaultPort: '9000', suggestedEnv: ['MINIO_ROOT_USER=admin', 'MINIO_ROOT_PASSWORD=secret', 'MINIO_CONSOLE_ADDRESS=:9001'], defaultTag: 'latest' },
70
+ wordpress: { requiredEnv: ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'], defaultPort: '8080', suggestedEnv: ['WORDPRESS_DB_HOST=db', 'WORDPRESS_DB_USER=wp', 'WORDPRESS_DB_PASSWORD=secret', 'WORDPRESS_DB_NAME=wordpress', 'WORDPRESS_TABLE_PREFIX=wp_'], defaultTag: '6.5-apache' },
71
71
  };
72
+
73
+ /**
74
+ * Given an image name (with or without tag), returns the image name with a
75
+ * default tag appended when the image matches a known profile and has no
76
+ * explicit tag. Returns the input unchanged otherwise.
77
+ *
78
+ * @param {string} imageName - e.g. 'postgres', 'postgres:15', 'unknownimage'
79
+ * @param {Record<string, { defaultTag: string }>} profiles
80
+ * @returns {string}
81
+ */
82
+ export function resolveImageTag(imageName, profiles = IMAGE_PROFILES) {
83
+ if (!imageName) return imageName;
84
+ if (imageName.includes(':')) return imageName;
85
+ const baseName = imageName.trim().toLowerCase().split('/').pop();
86
+ const profile = profiles[baseName];
87
+ if (!profile?.defaultTag) return imageName;
88
+ return `${imageName}:${profile.defaultTag}`;
89
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Docker Hub search helper.
3
+ * Pure function — no side effects beyond HTTP. All state management
4
+ * lives in the calling hook (useContainerCreation).
5
+ */
6
+
7
+ const DOCKER_HUB_SEARCH_URL =
8
+ 'https://hub.docker.com/v2/search/repositories/';
9
+
10
+ /**
11
+ * Format a Docker Hub search result object into a display string.
12
+ *
13
+ * Format rules:
14
+ * - Official: "name [★ official] [Npulls+]" (if pullCount > 0)
15
+ * - Non-official with pulls: "name [Npulls+]"
16
+ * - Otherwise: "name"
17
+ *
18
+ * Pull count suffixes: ≥1B → "1B+", ≥1M → "1M+", ≥1K → "1K+", else raw number
19
+ *
20
+ * @param {{ name: string, isOfficial: boolean, pullCount: number }} result
21
+ * @returns {string}
22
+ */
23
+ export function formatHubResult(result) {
24
+ const { name, isOfficial, pullCount } = result;
25
+
26
+ let pullSuffix = '';
27
+ if (pullCount >= 1e9) {
28
+ pullSuffix = ` [1B+ pulls]`;
29
+ } else if (pullCount >= 1e6) {
30
+ pullSuffix = ` [${Math.floor(pullCount / 1e6)}M+ pulls]`;
31
+ } else if (pullCount >= 1e3) {
32
+ pullSuffix = ` [${Math.floor(pullCount / 1e3)}K+ pulls]`;
33
+ } else if (pullCount > 0) {
34
+ pullSuffix = ` [${pullCount} pulls]`;
35
+ }
36
+
37
+ if (isOfficial) {
38
+ return `${name} [★ official]${pullSuffix}`;
39
+ }
40
+
41
+ return `${name}${pullSuffix}`;
42
+ }
43
+
44
+ /**
45
+ * Search Docker Hub for images matching the given query.
46
+ *
47
+ * @param {string} query - Search term (will be trimmed and encoded)
48
+ * @param {Object} [options]
49
+ * @param {AbortSignal} [options.signal] - Abort signal; defaults to AbortSignal.timeout(5000)
50
+ * @returns {Promise<Array<{ name: string, isOfficial: boolean, pullCount: number, description: string }>>}
51
+ * @throws {Error} If response.ok is false or the network call rejects
52
+ */
53
+ export async function searchDockerHub(query, options = {}) {
54
+ const q = query.trim();
55
+ if (!q) return [];
56
+
57
+ const url = `${DOCKER_HUB_SEARCH_URL}?query=${encodeURIComponent(q)}&page_size=10`;
58
+ const signal = options.signal ?? AbortSignal.timeout(5000);
59
+
60
+ const response = await fetch(url, { signal });
61
+
62
+ if (!response.ok) {
63
+ throw new Error(`Docker Hub search failed: ${response.status}`);
64
+ }
65
+
66
+ const data = await response.json();
67
+ const results = data.results ?? [];
68
+
69
+ return results.map((item) => ({
70
+ name: item.repo_name,
71
+ isOfficial: item.is_official,
72
+ pullCount: item.pull_count,
73
+ description: item.short_description,
74
+ }));
75
+ }
@@ -58,8 +58,7 @@ 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
64
  const method = level === 'debug' ? 'log' : level;
@@ -1,4 +1,4 @@
1
- import { useState } from 'react';
1
+ import { useState, useRef, useEffect } from 'react';
2
2
  import { safeCall } from '../../helpers/safeCall.js';
3
3
  import {
4
4
  startContainer as svcStartContainer,
@@ -20,6 +20,19 @@ export function useContainerActions({ containers, onAction }) {
20
20
  const [message, setMessage] = useState('');
21
21
  const [messageColor, setMessageColor] = useState('yellow');
22
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), []);
35
+
23
36
  /**
24
37
  * Handles a generic container action and sets feedback.
25
38
  * @param {Function} actionFn - The async action function (start, stop, etc)
@@ -42,20 +55,20 @@ export function useContainerActions({ containers, onAction }) {
42
55
  setMessageColor('yellow');
43
56
  try {
44
57
  await actionFn(container.id);
45
- setMessage(`${actionLabel} container successful.`);
46
- setMessageColor('green');
58
+ setTimedMessage(`${actionLabel} container successful.`, 'green');
47
59
  safeCall(onAction);
48
60
  } catch (err) {
49
- setMessage(
50
- `Failed to ${actionLabel.toLowerCase()} container: ${err.message}`
61
+ setTimedMessage(
62
+ `Failed to ${actionLabel.toLowerCase()} container: ${err.message}`,
63
+ 'red'
51
64
  );
52
- setMessageColor('red');
53
65
  }
54
66
  }
55
67
 
56
68
  return {
57
69
  message,
58
70
  setMessage,
71
+ setTimedMessage,
59
72
  messageColor,
60
73
  setMessageColor,
61
74
  handleAction,