imcp 0.0.18 → 0.1.1
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/.roo/rules-code/rules.md +88 -0
- package/dist/cli/index.js +0 -0
- package/dist/core/metadatas/constants.d.ts +7 -0
- package/dist/core/metadatas/constants.js +7 -0
- package/dist/core/onboard/FeedOnboardService.d.ts +7 -3
- package/dist/core/onboard/FeedOnboardService.js +52 -5
- package/dist/core/onboard/OnboardProcessor.js +22 -22
- package/dist/services/MCPManager.js +66 -6
- package/dist/services/TelemetryService.d.ts +15 -0
- package/dist/services/TelemetryService.js +54 -0
- package/dist/utils/githubAuth.js +65 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +78 -1
- package/dist/utils/versionUtils.d.ts +1 -0
- package/dist/utils/versionUtils.js +29 -0
- package/dist/web/public/css/serverCategoryList.css +120 -0
- package/dist/web/public/index.html +6 -3
- package/dist/web/public/js/flights/flights.js +0 -1
- package/dist/web/public/js/onboard/formProcessor.js +18 -11
- package/dist/web/public/js/onboard/publishHandler.js +30 -0
- package/dist/web/public/js/onboard/templates.js +5 -1
- package/dist/web/public/js/onboard/uiHandlers.js +266 -39
- package/dist/web/public/js/onboard/validationHandlers.js +71 -39
- package/dist/web/public/js/serverCategoryList.js +91 -7
- package/dist/web/public/onboard.html +2 -2
- package/dist/web/server.js +11 -1
- package/{src/web/public/js/onboard → docs}/ONBOARDING_PAGE_DESIGN.md +15 -125
- package/docs/Telemetry.md +136 -0
- package/memory-bank/activeContext.md +14 -0
- package/memory-bank/decisionLog.md +28 -0
- package/memory-bank/productContext.md +41 -0
- package/memory-bank/progress.md +5 -0
- package/memory-bank/systemPatterns.md +3 -0
- package/package.json +2 -1
- package/src/core/metadatas/constants.ts +9 -0
- package/src/core/onboard/FeedOnboardService.ts +59 -5
- package/src/core/onboard/OnboardProcessor.ts +25 -23
- package/src/services/MCPManager.ts +78 -8
- package/src/services/TelemetryService.ts +59 -0
- package/src/utils/githubAuth.ts +84 -1
- package/src/utils/logger.ts +83 -1
- package/src/utils/versionUtils.ts +33 -0
- package/src/web/public/css/serverCategoryList.css +120 -0
- package/src/web/public/index.html +6 -3
- package/src/web/public/js/onboard/formProcessor.js +18 -11
- package/src/web/public/js/onboard/publishHandler.js +30 -0
- package/src/web/public/js/onboard/templates.js +5 -1
- package/src/web/public/js/onboard/uiHandlers.js +266 -39
- package/src/web/public/js/onboard/validationHandlers.js +71 -39
- package/src/web/public/js/serverCategoryList.js +91 -7
- package/src/web/public/onboard.html +2 -2
- package/src/web/server.ts +11 -1
- package/dist/cli/commands/start.d.ts +0 -2
- package/dist/cli/commands/start.js +0 -32
- package/dist/cli/commands/sync.d.ts +0 -2
- package/dist/cli/commands/sync.js +0 -17
- package/dist/core/ConfigurationLoader.d.ts +0 -32
- package/dist/core/ConfigurationLoader.js +0 -236
- package/dist/core/ConfigurationProvider.d.ts +0 -35
- package/dist/core/ConfigurationProvider.js +0 -375
- package/dist/core/InstallationService.d.ts +0 -50
- package/dist/core/InstallationService.js +0 -350
- package/dist/core/MCPManager.d.ts +0 -28
- package/dist/core/MCPManager.js +0 -188
- package/dist/core/RequirementService.d.ts +0 -40
- package/dist/core/RequirementService.js +0 -110
- package/dist/core/ServerSchemaLoader.d.ts +0 -11
- package/dist/core/ServerSchemaLoader.js +0 -43
- package/dist/core/ServerSchemaProvider.d.ts +0 -17
- package/dist/core/ServerSchemaProvider.js +0 -120
- package/dist/core/constants.d.ts +0 -47
- package/dist/core/constants.js +0 -94
- package/dist/core/installers/BaseInstaller.d.ts +0 -74
- package/dist/core/installers/BaseInstaller.js +0 -253
- package/dist/core/installers/ClientInstaller.d.ts +0 -23
- package/dist/core/installers/ClientInstaller.js +0 -564
- package/dist/core/installers/CommandInstaller.d.ts +0 -37
- package/dist/core/installers/CommandInstaller.js +0 -173
- package/dist/core/installers/GeneralInstaller.d.ts +0 -33
- package/dist/core/installers/GeneralInstaller.js +0 -85
- package/dist/core/installers/InstallerFactory.d.ts +0 -54
- package/dist/core/installers/InstallerFactory.js +0 -97
- package/dist/core/installers/NpmInstaller.d.ts +0 -26
- package/dist/core/installers/NpmInstaller.js +0 -127
- package/dist/core/installers/PipInstaller.d.ts +0 -28
- package/dist/core/installers/PipInstaller.js +0 -127
- package/dist/core/installers/RequirementInstaller.d.ts +0 -33
- package/dist/core/installers/RequirementInstaller.js +0 -3
- package/dist/core/types.d.ts +0 -166
- package/dist/core/types.js +0 -16
- package/dist/services/InstallRequestValidator.d.ts +0 -21
- package/dist/services/InstallRequestValidator.js +0 -99
- package/dist/web/public/js/modal/installHandler.js +0 -227
- package/dist/web/public/js/modal/loadingUI.js +0 -74
- package/dist/web/public/js/modal/modalUI.js +0 -214
- package/dist/web/public/js/modal/version.js +0 -20
|
@@ -70,29 +70,29 @@ function reindexServers(serversListId = 'serversList') {
|
|
|
70
70
|
// Ensure the server header toggle onclick is correctly re-indexed for its ID parameters
|
|
71
71
|
const headerToggle = serverItem.querySelector(`#${serversListId}-server-header-${oldServerIndex}`);
|
|
72
72
|
if (headerToggle) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
73
|
+
headerToggle.id = `${serversListId}-server-header-${newServerIndex}`;
|
|
74
|
+
const oldContentId = `${serversListId}-server-content-${oldServerIndex}`;
|
|
75
|
+
const newContentId = `${serversListId}-server-content-${newServerIndex}`;
|
|
76
|
+
let onclickAttr = headerToggle.getAttribute('onclick');
|
|
77
|
+
if (onclickAttr) {
|
|
78
|
+
onclickAttr = onclickAttr.replace(new RegExp(oldContentId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newContentId);
|
|
79
|
+
headerToggle.setAttribute('onclick', onclickAttr);
|
|
80
|
+
}
|
|
81
|
+
let onkeydownAttr = headerToggle.getAttribute('onkeydown');
|
|
82
|
+
if (onkeydownAttr) {
|
|
83
|
+
onkeydownAttr = onkeydownAttr.replace(new RegExp(oldContentId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newContentId);
|
|
84
|
+
headerToggle.setAttribute('onkeydown', onkeydownAttr);
|
|
85
|
+
}
|
|
86
|
+
headerToggle.setAttribute('aria-controls', newContentId);
|
|
87
87
|
}
|
|
88
88
|
// Update title ID for aria-labelledby
|
|
89
89
|
const titleForAria = serverItem.querySelector(`#${serversListId}-server-title-${oldServerIndex}`);
|
|
90
90
|
if (titleForAria) {
|
|
91
|
-
|
|
91
|
+
titleForAria.id = `${serversListId}-server-title-${newServerIndex}`;
|
|
92
92
|
}
|
|
93
93
|
const contentRegion = serverItem.querySelector(`#${serversListId}-server-content-${oldServerIndex}`);
|
|
94
94
|
if (contentRegion) {
|
|
95
|
-
|
|
95
|
+
contentRegion.setAttribute('aria-labelledby', `${serversListId}-server-title-${newServerIndex}`);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
|
|
@@ -225,17 +225,24 @@ export function addServer(serversListId = 'serversList', isContextGenerallyReadO
|
|
|
225
225
|
|
|
226
226
|
const serverItem = container.querySelector(`.server-item[data-index="${newServerIndex}"]`);
|
|
227
227
|
if (serverItem) {
|
|
228
|
-
|
|
228
|
+
// Handle systemTags dataset
|
|
229
|
+
if (serverData && serverData.systemTags) {
|
|
229
230
|
serverItem.dataset.systemTags = JSON.stringify(serverData.systemTags);
|
|
230
|
-
} else if (serverData && !serverData.systemTags) {
|
|
231
|
-
// Ensure no stale adhoc tag if the DOM element was somehow reused and had it previously.
|
|
231
|
+
} else if (serverData && !serverData.systemTags) {
|
|
232
232
|
delete serverItem.dataset.systemTags;
|
|
233
|
-
} else if (!serverData) {
|
|
234
|
-
// A brand new server is NOT adhoc by default. It becomes adhoc if modified (e.g. via JSON).
|
|
235
|
-
// This fulfills part of Req 3: newly added one in create-server tab should not have systemTags.
|
|
236
|
-
// And implicitly part of Req 2: new server in create-category tab also won't have systemTags.
|
|
233
|
+
} else if (!serverData) {
|
|
237
234
|
delete serverItem.dataset.systemTags;
|
|
238
235
|
}
|
|
236
|
+
|
|
237
|
+
// Handle originalName dataset
|
|
238
|
+
if (serverData && serverData.name) {
|
|
239
|
+
serverItem.dataset.originalName = serverData.name;
|
|
240
|
+
} else {
|
|
241
|
+
// If it's a new server (serverData is null) or serverData has no name,
|
|
242
|
+
// ensure no stale originalName if the DOM element was somehow reused.
|
|
243
|
+
delete serverItem.dataset.originalName;
|
|
244
|
+
}
|
|
245
|
+
|
|
239
246
|
setupServerMode(serverItem, newServerIndex, serversListId, actualReadOnlyForThisServer, serverData);
|
|
240
247
|
// Pass serverData to setupReadOnlyState so it can accurately determine adhoc status for title.
|
|
241
248
|
setupReadOnlyState(serverItem, actualReadOnlyForThisServer, serverData, serversListId, newServerIndex);
|
|
@@ -334,11 +341,11 @@ function setupReadOnlyState(serverItem, isEffectivelyReadOnly, serverDataFromPop
|
|
|
334
341
|
// If it's somehow missing and it's adhoc, ensure it's present.
|
|
335
342
|
// This situation should be rare if template logic is correct.
|
|
336
343
|
if (!titleElement.querySelector('span.text-blue-600')) {
|
|
337
|
-
|
|
344
|
+
titleElement.innerHTML = `${baseTitle} <span class="text-sm text-blue-600 ml-1">(Adhoc - Editable)</span>`;
|
|
338
345
|
} else {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
346
|
+
// Ensure base title is correct if adhoc span is already there
|
|
347
|
+
const adhocSpanHTML = titleElement.querySelector('span.text-blue-600').outerHTML;
|
|
348
|
+
titleElement.innerHTML = `${baseTitle} ${adhocSpanHTML}`;
|
|
342
349
|
}
|
|
343
350
|
}
|
|
344
351
|
}
|
|
@@ -353,10 +360,20 @@ function setupReadOnlyState(serverItem, isEffectivelyReadOnly, serverDataFromPop
|
|
|
353
360
|
}
|
|
354
361
|
});
|
|
355
362
|
|
|
356
|
-
// Hide
|
|
363
|
+
// Hide action buttons (Add Dependency, Add Env Var, Remove Server) for this server if it's read-only,
|
|
364
|
+
// but ensure the "Duplicate Server" button remains visible.
|
|
357
365
|
serverItem.querySelectorAll('.action-button-in-server').forEach(btn => {
|
|
366
|
+
if (!btn.classList.contains('duplicate-mcp-server-button')) {
|
|
367
|
+
// This is NOT the duplicate button, so hide it if the server is read-only.
|
|
358
368
|
btn.style.display = 'none';
|
|
359
369
|
btn.classList.add('hidden');
|
|
370
|
+
} else {
|
|
371
|
+
// This IS the duplicate button. Ensure it's visible.
|
|
372
|
+
// (It should be visible by default as it doesn't have conditional hide classes from the template)
|
|
373
|
+
btn.style.display = 'flex'; // Or its appropriate display style
|
|
374
|
+
btn.classList.remove('hidden');
|
|
375
|
+
btn.disabled = false; // Ensure it's enabled
|
|
376
|
+
}
|
|
360
377
|
});
|
|
361
378
|
|
|
362
379
|
// Expand server content and all key sections
|
|
@@ -385,11 +402,11 @@ function setupReadOnlyState(serverItem, isEffectivelyReadOnly, serverDataFromPop
|
|
|
385
402
|
// Check if the adhoc span is already there from the template.
|
|
386
403
|
// If not (e.g. server became adhoc after initial render and this is a re-evaluation), add it.
|
|
387
404
|
if (!titleElement.querySelector('span.text-blue-600')) {
|
|
388
|
-
|
|
405
|
+
titleElement.innerHTML = `${baseTitle} <span class="text-sm text-blue-600 ml-1">(Adhoc - Editable)</span>`;
|
|
389
406
|
} else {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
407
|
+
// Ensure base title is correct if adhoc span is already there
|
|
408
|
+
const adhocSpanHTML = titleElement.querySelector('span.text-blue-600').outerHTML;
|
|
409
|
+
titleElement.innerHTML = `${baseTitle} ${adhocSpanHTML}`;
|
|
393
410
|
}
|
|
394
411
|
} else {
|
|
395
412
|
// Editable and not adhoc, just the base title.
|
|
@@ -399,19 +416,19 @@ function setupReadOnlyState(serverItem, isEffectivelyReadOnly, serverDataFromPop
|
|
|
399
416
|
setTimeout(() => {
|
|
400
417
|
// Enable main server fields
|
|
401
418
|
serverItem.querySelectorAll('input, select, textarea').forEach(el => {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
419
|
+
if (!el.closest('.env-var-item') && !el.closest('.server-requirement-item')) {
|
|
420
|
+
el.disabled = false;
|
|
421
|
+
el.removeAttribute('readonly'); // Ensure readonly is also removed
|
|
422
|
+
el.classList.remove('bg-gray-100', 'cursor-not-allowed', 'opacity-70');
|
|
406
423
|
}
|
|
407
424
|
});
|
|
408
425
|
|
|
409
426
|
// Show ALL action buttons for this server (Add Dependency, Add Env Var, Remove Server)
|
|
410
427
|
// as the server is determined to be effectively editable.
|
|
411
428
|
serverItem.querySelectorAll('.action-button-in-server').forEach(btn => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
429
|
+
btn.style.display = 'flex'; // Or 'inline-flex' or whatever its default visible display is
|
|
430
|
+
btn.disabled = false;
|
|
431
|
+
btn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
|
|
415
432
|
});
|
|
416
433
|
}, 0);
|
|
417
434
|
}
|
|
@@ -832,6 +849,216 @@ export async function copyJsonToClipboard() {
|
|
|
832
849
|
}
|
|
833
850
|
}
|
|
834
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Duplicates an MCP server item in the form.
|
|
854
|
+
* @param {number} serverIndexToDuplicate - The index of the server to duplicate.
|
|
855
|
+
* @param {string} serversListId - The ID of the servers list container (e.g., 'serversList', 'existingCategoryServersList').
|
|
856
|
+
*/
|
|
857
|
+
export function duplicateServer(serverIndexToDuplicate, serversListId) {
|
|
858
|
+
const formId = serversListId === 'serversList' ? 'onboardForm' : 'onboardServerForm';
|
|
859
|
+
const formElement = document.getElementById(formId);
|
|
860
|
+
if (!formElement) {
|
|
861
|
+
showToast(`Form with ID ${formId} not found. Cannot duplicate server.`, 'error');
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Determine if we are in the "Create Server in Existing Category" tab context
|
|
866
|
+
const isExistingCategoryContext = serversListId === 'existingCategoryServersList';
|
|
867
|
+
let baseCategoryDataForFormExtraction = null;
|
|
868
|
+
if (isExistingCategoryContext) {
|
|
869
|
+
// If in existing category context, getFormData might need the base category data
|
|
870
|
+
// This depends on how getFormData is structured to handle this tab.
|
|
871
|
+
// Assuming window.currentSelectedCategoryData holds the loaded category.
|
|
872
|
+
baseCategoryDataForFormExtraction = window.currentSelectedCategoryData || null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const serverItemToDuplicate = formElement.querySelector(`.server-item[data-index="${serverIndexToDuplicate}"]`);
|
|
876
|
+
if (!serverItemToDuplicate) {
|
|
877
|
+
showToast('Could not find the server item to duplicate in the DOM.', 'error');
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Track and temporarily enable ALL disabled fields in the form to ensure we get complete data
|
|
882
|
+
const disabledFields = [];
|
|
883
|
+
formElement.querySelectorAll('input, select, textarea').forEach(el => {
|
|
884
|
+
if (el.disabled) {
|
|
885
|
+
disabledFields.push({element: el, wasDisabled: true});
|
|
886
|
+
el.disabled = false;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Get all current server data from the form
|
|
891
|
+
const currentFullFeedConfig = getFormData(formElement, isExistingCategoryContext, baseCategoryDataForFormExtraction);
|
|
892
|
+
|
|
893
|
+
// Restore original disabled state for all fields
|
|
894
|
+
disabledFields.forEach(({element, wasDisabled}) => {
|
|
895
|
+
element.disabled = wasDisabled;
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (!currentFullFeedConfig || !currentFullFeedConfig.mcpServers ||
|
|
899
|
+
currentFullFeedConfig.mcpServers.length <= serverIndexToDuplicate) {
|
|
900
|
+
showToast('Could not retrieve data for the server to duplicate after attempting to enable fields.', 'error');
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 2. Get the specific server data to duplicate (deep copy)
|
|
905
|
+
let serverToDuplicateData = JSON.parse(JSON.stringify(currentFullFeedConfig.mcpServers[serverIndexToDuplicate]));
|
|
906
|
+
|
|
907
|
+
// 3. IMPORTANT: Remove systemTags from the duplicated data.
|
|
908
|
+
// The duplicated server should be treated as a new, fully editable server.
|
|
909
|
+
if (serverToDuplicateData.systemTags) {
|
|
910
|
+
delete serverToDuplicateData.systemTags;
|
|
911
|
+
}
|
|
912
|
+
// Also, ensure its name is distinct if needed, or clear it to force user input.
|
|
913
|
+
// For now, we'll copy the name but it will be editable.
|
|
914
|
+
// Consider adding a suffix like "-copy" to the name if automatic distinct names are desired.
|
|
915
|
+
// serverToDuplicateData.name = serverToDuplicateData.name ? `${serverToDuplicateData.name}-copy` : 'duplicated-server';
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
// 4. Determine if the general context for adding a new server is read-only
|
|
919
|
+
// This is primarily for the 'Edit Existing Category' tab.
|
|
920
|
+
// 'Create New Category' tab is never read-only in this sense.
|
|
921
|
+
let isContextGenerallyReadOnlyForAddServer = false;
|
|
922
|
+
if (isExistingCategoryContext) {
|
|
923
|
+
isContextGenerallyReadOnlyForAddServer = window.isExistingCategoryReadOnly || false;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 5. Add a new server item. Pass `null` for serverData to ensure it's treated as a new item.
|
|
927
|
+
// The `isContextGenerallyReadOnlyForAddServer` helps `addServer` decide initial template state.
|
|
928
|
+
const newServerIndex = addServer(serversListId, isContextGenerallyReadOnlyForAddServer, null);
|
|
929
|
+
|
|
930
|
+
if (newServerIndex === -1) {
|
|
931
|
+
showToast('Failed to add a new server item for duplication.', 'error');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// 6. Populate the new server item with the copied data.
|
|
936
|
+
// The new server should be fully editable.
|
|
937
|
+
// We need to call populateForm or a similar function for just this new server.
|
|
938
|
+
// populateForm expects a full FeedConfiguration. We'll construct a minimal one.
|
|
939
|
+
const tempFeedConfigForPopulation = {
|
|
940
|
+
mcpServers: [serverToDuplicateData] // Contains only the server data to populate
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Call populateForm, but ensure it targets the *new* server index and makes it editable.
|
|
944
|
+
// The populateForm function needs to be aware it's populating a *specific, new* server.
|
|
945
|
+
// The last `true` tells populateForm to make this specific server editable, overriding general read-only context.
|
|
946
|
+
// This might require adjustments in populateForm or a dedicated populateSingleServerForm function.
|
|
947
|
+
// For now, assuming populateForm can handle this if we pass a single server and target the new index.
|
|
948
|
+
// We pass `false` for `renderServersAsReadOnly` to ensure the duplicated server is editable.
|
|
949
|
+
// And we pass `true` for a hypothetical `forceEditableForSingleServer` if populateForm supported it.
|
|
950
|
+
// Let's simplify: populateForm will populate based on the data. addServer already set it up as editable.
|
|
951
|
+
// We need to ensure that `populateForm` correctly populates the server at `newServerIndex`.
|
|
952
|
+
// The `populateForm` function in formProcessor.js needs to be able to populate a *specific* server
|
|
953
|
+
// if we pass only one server in `mcpServers`.
|
|
954
|
+
|
|
955
|
+
// Get the newly added server item
|
|
956
|
+
const serversList = document.getElementById(serversListId);
|
|
957
|
+
const newServerItem = serversList.querySelector(`.server-item[data-index="${newServerIndex}"]`);
|
|
958
|
+
|
|
959
|
+
if (newServerItem) {
|
|
960
|
+
// Directly populate fields for the new server.
|
|
961
|
+
// This is a simplified version of what populateForm does for a single server.
|
|
962
|
+
// This avoids needing to modify populateForm extensively for this specific use case.
|
|
963
|
+
populateServerManually(newServerItem, newServerIndex, serverToDuplicateData, serversListId);
|
|
964
|
+
showToast(`Server #${serverIndexToDuplicate + 1} duplicated to Server #${newServerIndex + 1}.`, 'success');
|
|
965
|
+
} else {
|
|
966
|
+
showToast('Failed to find the new server item after duplication.', 'error');
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
window.duplicateServer = duplicateServer;
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Manually populates a single server item's form fields with provided data.
|
|
973
|
+
* This is a helper for the duplicateServer functionality.
|
|
974
|
+
* @param {HTMLElement} serverItemElement - The HTML element of the server item.
|
|
975
|
+
* @param {number} serverIndex - The index of the server item.
|
|
976
|
+
* @param {object} serverData - The data object for the server.
|
|
977
|
+
* @param {string} serversListId - The ID of the servers list.
|
|
978
|
+
*/
|
|
979
|
+
function populateServerManually(serverItemElement, serverIndex, serverData, serversListId) {
|
|
980
|
+
// Populate basic fields
|
|
981
|
+
serverItemElement.querySelector(`input[name="servers[${serverIndex}].name"]`).value = serverData.name || '';
|
|
982
|
+
serverItemElement.querySelector(`textarea[name="servers[${serverIndex}].description"]`).value = serverData.description || '';
|
|
983
|
+
serverItemElement.querySelector(`select[name="servers[${serverIndex}].mode"]`).value = serverData.mode || 'stdio';
|
|
984
|
+
serverItemElement.querySelector(`input[name="servers[${serverIndex}].schemas"]`).value = serverData.schemas || '';
|
|
985
|
+
serverItemElement.querySelector(`input[name="servers[${serverIndex}].repository"]`).value = serverData.repository || '';
|
|
986
|
+
|
|
987
|
+
// Trigger change on mode to render correct installation config
|
|
988
|
+
const modeSelect = serverItemElement.querySelector(`select[name="servers[${serverIndex}].mode"]`);
|
|
989
|
+
if (modeSelect) {
|
|
990
|
+
modeSelect.dispatchEvent(new Event('change')); // This will call renderInstallationConfig
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Populate installation config (command/args for stdio, url for sse)
|
|
994
|
+
// renderInstallationConfig should have been called by the modeSelect change event.
|
|
995
|
+
// Now, set the values based on serverData.installation
|
|
996
|
+
if (serverData.mode === 'stdio' && serverData.installation) {
|
|
997
|
+
const commandInput = serverItemElement.querySelector(`input[name="servers[${serverIndex}].installation.command"]`);
|
|
998
|
+
if (commandInput) commandInput.value = serverData.installation.command || '';
|
|
999
|
+
const argsInput = serverItemElement.querySelector(`input[name="servers[${serverIndex}].installation.args"]`);
|
|
1000
|
+
if (argsInput) argsInput.value = serverData.installation.args ? serverData.installation.args.join(', ') : '';
|
|
1001
|
+
} else if (serverData.mode === 'sse' && serverData.installation) {
|
|
1002
|
+
const urlInput = serverItemElement.querySelector(`input[name="servers[${serverIndex}].installation.url"]`);
|
|
1003
|
+
if (urlInput) urlInput.value = serverData.installation.url || '';
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Populate environment variables
|
|
1007
|
+
const envVarsContainer = serverItemElement.querySelector(`#envVarsContainer_${serverIndex}`);
|
|
1008
|
+
if (envVarsContainer && serverData.installation && serverData.installation.env) {
|
|
1009
|
+
Object.entries(serverData.installation.env).forEach(([name, envConfig]) => {
|
|
1010
|
+
const envVarIndex = addEnvVariable(serverIndex, serversListId, false); // Add as editable
|
|
1011
|
+
const envVarItem = envVarsContainer.querySelector(`.env-var-item[data-env-index="${envVarIndex}"]`);
|
|
1012
|
+
if (envVarItem) {
|
|
1013
|
+
envVarItem.querySelector(`input[name="servers[${serverIndex}].installation.env[${envVarIndex}].name"]`).value = name;
|
|
1014
|
+
envVarItem.querySelector(`input[name="servers[${serverIndex}].installation.env[${envVarIndex}].default"]`).value = envConfig.Default || '';
|
|
1015
|
+
envVarItem.querySelector(`input[name="servers[${serverIndex}].installation.env[${envVarIndex}].required"]`).checked = envConfig.Required || false;
|
|
1016
|
+
envVarItem.querySelector(`textarea[name="servers[${serverIndex}].installation.env[${envVarIndex}].description"]`).value = envConfig.Description || '';
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Populate server requirements (dependencies)
|
|
1022
|
+
const serverReqsContainer = serverItemElement.querySelector(`#server-requirements-list-${serverIndex}`);
|
|
1023
|
+
if (serverReqsContainer && serverData.dependencies && serverData.dependencies.requirements) {
|
|
1024
|
+
serverData.dependencies.requirements.forEach(req => {
|
|
1025
|
+
const reqIndex = addServerRequirement(serverIndex, serversListId, false); // Add as editable
|
|
1026
|
+
const reqItem = serverReqsContainer.querySelector(`.server-requirement-item[data-req-index="${reqIndex}"]`);
|
|
1027
|
+
if (reqItem) {
|
|
1028
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].name"]`).value = req.name || '';
|
|
1029
|
+
reqItem.querySelector(`select[name="servers[${serverIndex}].requirements[${reqIndex}].type"]`).value = req.type || '';
|
|
1030
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].version"]`).value = req.version || '';
|
|
1031
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].order"]`).value = req.order || '';
|
|
1032
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].alias"]`).value = req.alias || '';
|
|
1033
|
+
|
|
1034
|
+
const typeSelect = reqItem.querySelector(`select[name="servers[${serverIndex}].requirements[${reqIndex}].type"]`);
|
|
1035
|
+
if (typeSelect) typeSelect.dispatchEvent(new Event('change')); // To show/hide alias
|
|
1036
|
+
|
|
1037
|
+
const registryTypeSelect = reqItem.querySelector(`select[name="servers[${serverIndex}].requirements[${reqIndex}].registryType"]`);
|
|
1038
|
+
if (registryTypeSelect) {
|
|
1039
|
+
registryTypeSelect.value = req.registryType || 'public';
|
|
1040
|
+
registryTypeSelect.dispatchEvent(new Event('change')); // To show/hide specific registry configs
|
|
1041
|
+
|
|
1042
|
+
if (req.registry) {
|
|
1043
|
+
if (req.registryType === 'github' && req.registry.githubRelease) {
|
|
1044
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.githubRelease.repository"]`).value = req.registry.githubRelease.repository || '';
|
|
1045
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.githubRelease.assetsName"]`).value = req.registry.githubRelease.assetsName || '';
|
|
1046
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.githubRelease.assetName"]`).value = req.registry.githubRelease.assetName || '';
|
|
1047
|
+
} else if (req.registryType === 'artifacts' && req.registry.artifacts) {
|
|
1048
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.artifacts.registryName"]`).value = req.registry.artifacts.registryName || '';
|
|
1049
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.artifacts.registryUrl"]`).value = req.registry.artifacts.registryUrl || '';
|
|
1050
|
+
} else if (req.registryType === 'local' && req.registry.local) {
|
|
1051
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.local.localPath"]`).value = req.registry.local.localPath || '';
|
|
1052
|
+
reqItem.querySelector(`input[name="servers[${serverIndex}].requirements[${reqIndex}].registry.local.assetName"]`).value = req.registry.local.assetName || '';
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
// Ensure the duplicated server is fully editable by calling setupReadOnlyState with false
|
|
1060
|
+
setupReadOnlyState(serverItemElement, false, null, serversListId, serverIndex);
|
|
1061
|
+
}
|
|
835
1062
|
Object.entries({
|
|
836
1063
|
addServer,
|
|
837
1064
|
removeServer,
|
|
@@ -9,7 +9,7 @@ import { toggleSectionContent } from './uiHandlers.js'; // Assuming this is corr
|
|
|
9
9
|
* @param {string} message - The validation message to display.
|
|
10
10
|
* @param {boolean} isError - Whether this is an error message (true) or success message (false).
|
|
11
11
|
*/
|
|
12
|
-
function showValidationMessage(element, message, isError = true) {
|
|
12
|
+
export function showValidationMessage(element, message, isError = true) {
|
|
13
13
|
// Remove any existing validation message
|
|
14
14
|
const existingMessage = element.nextElementSibling;
|
|
15
15
|
if (existingMessage && existingMessage.classList.contains('validation-message')) {
|
|
@@ -34,6 +34,72 @@ function showValidationMessage(element, message, isError = true) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Common form field validation used by both validate and publish handlers
|
|
39
|
+
* @param {HTMLFormElement} form - The form to validate
|
|
40
|
+
* @param {string} activeTab - The active tab ('create-category' or 'create-server')
|
|
41
|
+
* @returns {{isValid: boolean, errors: string[]}} Validation result with error messages
|
|
42
|
+
*/
|
|
43
|
+
export function validateFormFields(form, activeTab) {
|
|
44
|
+
// Clear any existing validation messages
|
|
45
|
+
form.querySelectorAll('.validation-message').forEach(el => el.remove());
|
|
46
|
+
|
|
47
|
+
let hasErrors = false;
|
|
48
|
+
const errors = [];
|
|
49
|
+
|
|
50
|
+
// Check for empty required fields and show validation messages
|
|
51
|
+
const emptyRequiredFields = Array.from(form.querySelectorAll('[required]'));
|
|
52
|
+
emptyRequiredFields.forEach(field => {
|
|
53
|
+
if (!field.value.trim()) {
|
|
54
|
+
showValidationMessage(field, 'This field is required', true);
|
|
55
|
+
hasErrors = true;
|
|
56
|
+
errors.push(`${field.name || 'Field'} is required`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Check category name format if this is the create category tab
|
|
61
|
+
if (activeTab === 'create-category') {
|
|
62
|
+
const nameInput = form.querySelector('input[name="name"]');
|
|
63
|
+
if (nameInput && nameInput.value.trim()) {
|
|
64
|
+
if (!/^[a-zA-Z0-9-_]+$/.test(nameInput.value.trim())) {
|
|
65
|
+
showValidationMessage(nameInput, 'Only alphanumeric characters, hyphens, and underscores allowed', true);
|
|
66
|
+
hasErrors = true;
|
|
67
|
+
errors.push('Category name must only contain alphanumeric characters, hyphens, and underscores');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for other validation errors
|
|
73
|
+
const envNameFields = form.querySelectorAll('input[name$=".env\\[\\].name"]');
|
|
74
|
+
envNameFields.forEach(field => {
|
|
75
|
+
const value = field.value.trim();
|
|
76
|
+
if (value && !/^[A-Z_][A-Z0-9_]*$/.test(value)) {
|
|
77
|
+
showValidationMessage(field, 'Must be uppercase with only letters, numbers, and underscores', true);
|
|
78
|
+
hasErrors = true;
|
|
79
|
+
errors.push('Environment variable names must be uppercase with only letters, numbers, and underscores');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const urlFields = form.querySelectorAll('input[name$=".url"]');
|
|
84
|
+
urlFields.forEach(field => {
|
|
85
|
+
const value = field.value.trim();
|
|
86
|
+
if (value) {
|
|
87
|
+
try {
|
|
88
|
+
new URL(value);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
showValidationMessage(field, 'Invalid URL format', true);
|
|
91
|
+
hasErrors = true;
|
|
92
|
+
errors.push('Invalid URL format');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
isValid: !hasErrors,
|
|
99
|
+
errors
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
37
103
|
const POLLING_INTERVAL = 3000; // 3 seconds
|
|
38
104
|
let pollingIntervalId = null;
|
|
39
105
|
|
|
@@ -278,45 +344,11 @@ export async function handleValidation(event, activeTab, currentSelectedCategory
|
|
|
278
344
|
return;
|
|
279
345
|
}
|
|
280
346
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// Check for empty required fields and show validation messages
|
|
285
|
-
const emptyRequiredFields = Array.from(onboardForm.querySelectorAll('[required]'));
|
|
286
|
-
let hasErrors = false;
|
|
287
|
-
|
|
288
|
-
emptyRequiredFields.forEach(field => {
|
|
289
|
-
if (!field.value.trim()) {
|
|
290
|
-
showValidationMessage(field, 'This field is required', true);
|
|
291
|
-
hasErrors = true;
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// Check for other validation errors
|
|
296
|
-
const envNameFields = onboardForm.querySelectorAll('input[name$=".env\\[\\].name"]');
|
|
297
|
-
envNameFields.forEach(field => {
|
|
298
|
-
const value = field.value.trim();
|
|
299
|
-
if (value && !/^[A-Z_][A-Z0-9_]*$/.test(value)) {
|
|
300
|
-
showValidationMessage(field, 'Must be uppercase with only letters, numbers, and underscores', true);
|
|
301
|
-
hasErrors = true;
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const urlFields = onboardForm.querySelectorAll('input[name$=".url"]');
|
|
306
|
-
urlFields.forEach(field => {
|
|
307
|
-
const value = field.value.trim();
|
|
308
|
-
if (value) {
|
|
309
|
-
try {
|
|
310
|
-
new URL(value);
|
|
311
|
-
} catch (e) {
|
|
312
|
-
showValidationMessage(field, 'Invalid URL format', true);
|
|
313
|
-
hasErrors = true;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
if (hasErrors) {
|
|
347
|
+
const validationResult = validateFormFields(onboardForm, activeTab);
|
|
348
|
+
if (!validationResult.isValid) {
|
|
319
349
|
showToast('Please fix all validation errors before proceeding.', 'error');
|
|
350
|
+
validateButton.disabled = false;
|
|
351
|
+
validateButton.innerHTML = "<i class='bx bx-check-shield mr-2'></i>Validate";
|
|
320
352
|
return;
|
|
321
353
|
}
|
|
322
354
|
|
|
@@ -3,6 +3,9 @@ import { showServerDetails } from './serverCategoryDetails.js'; // Still needed
|
|
|
3
3
|
import { showToast } from './notifications.js';
|
|
4
4
|
import { buildUrlWithFlights } from './flights/flights.js';
|
|
5
5
|
|
|
6
|
+
// Store state for categories
|
|
7
|
+
let pinnedCategories = {};
|
|
8
|
+
|
|
6
9
|
// Wait for data to be loaded
|
|
7
10
|
async function waitForData() {
|
|
8
11
|
if (allServerCategoriesData && allServerCategoriesData.length > 0) {
|
|
@@ -12,6 +15,54 @@ async function waitForData() {
|
|
|
12
15
|
return allServerCategoriesData && allServerCategoriesData.length > 0;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
// Load pinned state from localStorage
|
|
19
|
+
function loadPinnedState() {
|
|
20
|
+
const saved = localStorage.getItem('pinnedCategories');
|
|
21
|
+
if (saved) {
|
|
22
|
+
try {
|
|
23
|
+
pinnedCategories = JSON.parse(saved);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error('Error parsing pinned categories from localStorage:', e);
|
|
26
|
+
pinnedCategories = {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Save pinned state to localStorage
|
|
32
|
+
function savePinnedState() {
|
|
33
|
+
localStorage.setItem('pinnedCategories', JSON.stringify(pinnedCategories));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Toggle pin status for a category
|
|
37
|
+
function togglePinCategory(categoryName, event) {
|
|
38
|
+
// Stop propagation to prevent navigation
|
|
39
|
+
event.stopPropagation();
|
|
40
|
+
|
|
41
|
+
// Toggle pin status
|
|
42
|
+
if (pinnedCategories[categoryName]) {
|
|
43
|
+
delete pinnedCategories[categoryName];
|
|
44
|
+
} else {
|
|
45
|
+
pinnedCategories[categoryName] = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Save to localStorage
|
|
49
|
+
savePinnedState();
|
|
50
|
+
|
|
51
|
+
// Find the server item element and add a temporary animation class
|
|
52
|
+
const serverItem = event.target.closest('.server-item');
|
|
53
|
+
if (serverItem) {
|
|
54
|
+
serverItem.classList.add('pin-animation');
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
serverItem.classList.remove('pin-animation');
|
|
57
|
+
}, 300); // Animation duration
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Re-render list with updated pin status
|
|
61
|
+
if (allServerCategoriesData && allServerCategoriesData.length > 0) {
|
|
62
|
+
renderServerCategoryList(allServerCategoriesData);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
15
66
|
// Function to show the last selected category on page load
|
|
16
67
|
async function loadLastSelectedCategory() {
|
|
17
68
|
const lastSelected = localStorage.getItem('lastSelectedCategory');
|
|
@@ -33,8 +84,24 @@ function renderServerCategoryList(servers) {
|
|
|
33
84
|
document.getElementById('serverCategoryDetails').innerHTML = '<p>Select a server from the list to see details.</p>'; // Clear details
|
|
34
85
|
return;
|
|
35
86
|
}
|
|
87
|
+
|
|
88
|
+
// Load pinned state
|
|
89
|
+
loadPinnedState();
|
|
90
|
+
|
|
91
|
+
// Create a copy of the servers array to avoid modifying the original
|
|
92
|
+
const sortedServers = [...servers];
|
|
93
|
+
|
|
94
|
+
// Sort servers with pinned ones at the top
|
|
95
|
+
sortedServers.sort((a, b) => {
|
|
96
|
+
const isPinnedA = pinnedCategories[a.name] === true;
|
|
97
|
+
const isPinnedB = pinnedCategories[b.name] === true;
|
|
98
|
+
|
|
99
|
+
if (isPinnedA && !isPinnedB) return -1;
|
|
100
|
+
if (!isPinnedA && isPinnedB) return 1;
|
|
101
|
+
return 0; // Keep original order for items with same pin status
|
|
102
|
+
});
|
|
36
103
|
|
|
37
|
-
serverCategoryList.innerHTML =
|
|
104
|
+
serverCategoryList.innerHTML = sortedServers.map(server => {
|
|
38
105
|
let statusHtml = '';
|
|
39
106
|
|
|
40
107
|
// Add tool status summary if available
|
|
@@ -80,13 +147,27 @@ function renderServerCategoryList(servers) {
|
|
|
80
147
|
}
|
|
81
148
|
systemTagsHtml += '</div>';
|
|
82
149
|
}
|
|
150
|
+
// Check if this server is pinned
|
|
151
|
+
const isPinned = pinnedCategories[server.name] === true;
|
|
152
|
+
const pinnedClass = isPinned ? 'pinned' : '';
|
|
153
|
+
|
|
154
|
+
// Pin/unpin button with appropriate icon
|
|
155
|
+
const pinIcon = isPinned
|
|
156
|
+
? '<i class="bx bxs-pin"></i>' // Solid pin icon for pinned items
|
|
157
|
+
: '<i class="bx bx-pin"></i>'; // Outline pin icon for unpinned items
|
|
83
158
|
|
|
84
159
|
return `
|
|
85
|
-
<div class="server-item border border-gray-200 p-3 rounded hover:bg-gray-50 cursor-pointer transition duration-150 ease-in-out"
|
|
86
|
-
onclick="navigateToCategory('${server.name}')"
|
|
160
|
+
<div class="server-item border border-gray-200 p-3 rounded hover:bg-gray-50 cursor-pointer transition duration-150 ease-in-out ${pinnedClass}"
|
|
87
161
|
data-server-name="${server.name}">
|
|
88
|
-
<
|
|
89
|
-
|
|
162
|
+
<div class="flex justify-between items-center">
|
|
163
|
+
<h3 class="font-semibold text-gray-800" onclick="navigateToCategory('${server.name}')">${server.displayName || server.name}</h3>
|
|
164
|
+
<div class="flex items-center">
|
|
165
|
+
<div class="pin-button ${pinnedClass}" onclick="togglePinCategoryItem('${server.name}', event)" title="${isPinned ? 'Unpin' : 'Pin'} this category">
|
|
166
|
+
${pinIcon}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="text-sm text-gray-500 flex items-center mt-1" onclick="navigateToCategory('${server.name}')">
|
|
90
171
|
${statusHtml}
|
|
91
172
|
${systemTagsHtml}
|
|
92
173
|
</div>
|
|
@@ -101,6 +182,9 @@ function renderServerCategoryList(servers) {
|
|
|
101
182
|
// Setup search functionality
|
|
102
183
|
function setupSearch() {
|
|
103
184
|
const searchBox = document.getElementById('searchBox');
|
|
185
|
+
|
|
186
|
+
// Load pinned state on page load
|
|
187
|
+
loadPinnedState();
|
|
104
188
|
|
|
105
189
|
searchBox.addEventListener('input', async function () {
|
|
106
190
|
const searchTerm = this.value.toLowerCase();
|
|
@@ -151,7 +235,7 @@ function navigateToCategory(categoryName) {
|
|
|
151
235
|
// Alternatively, attach event listeners dynamically after rendering the list.
|
|
152
236
|
// For simplicity with current structure, we'll make it global.
|
|
153
237
|
window.navigateToCategory = navigateToCategory;
|
|
154
|
-
|
|
238
|
+
window.togglePinCategoryItem = togglePinCategory;
|
|
155
239
|
|
|
156
240
|
// Export functions
|
|
157
|
-
export { renderServerCategoryList, setupSearch, loadLastSelectedCategory };
|
|
241
|
+
export { renderServerCategoryList, setupSearch, loadLastSelectedCategory, togglePinCategory };
|
|
@@ -106,8 +106,8 @@
|
|
|
106
106
|
placeholder="e.g., Coder Tools">
|
|
107
107
|
</div>
|
|
108
108
|
<div class="md:col-span-2">
|
|
109
|
-
<label class="block text-sm font-medium text-gray-700 mb-1">Description
|
|
110
|
-
<textarea name="description" rows="3"
|
|
109
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Description*</label>
|
|
110
|
+
<textarea name="description" rows="3" required
|
|
111
111
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
112
112
|
placeholder="Describe the purpose and capabilities of this server category"></textarea>
|
|
113
113
|
</div>
|