From 76b32822d3d7c0eae3b583ea015b61c18db600fb Mon Sep 17 00:00:00 2001 From: Prakhar-Pandey0 Date: Thu, 23 Jul 2026 14:31:37 +0530 Subject: [PATCH 1/7] feat: add web GUI for Omnia configuration management. Add the complete utils/gui module with a React/Vite/TypeScript frontend and FastAPI backend, including catalog editor, configuration wizard, local repo management, adapter policy editor, and supporting shared components and services with L1/L2 validations. Signed-off-by: Prakhar-Pandey0 --- utils/gui/README.md | 392 +++ utils/gui/backend/.gitignore | 75 + utils/gui/backend/__init__.py | 10 + utils/gui/backend/api/__init__.py | 1 + utils/gui/backend/api/v1/__init__.py | 25 + utils/gui/backend/api/v1/dependencies.py | 132 + utils/gui/backend/api/v1/routes/__init__.py | 15 + .../api/v1/routes/adapter_policy_routes.py | 61 + .../api/v1/routes/catalog_editor_routes.py | 200 ++ .../backend/api/v1/routes/catalog_routes.py | 399 +++ .../api/v1/routes/local_repo_routes.py | 94 + .../backend/api/v1/routes/wizard_routes.py | 186 ++ utils/gui/backend/api/v1/schemas/__init__.py | 29 + .../api/v1/schemas/catalog_editor_schemas.py | 68 + .../backend/api/v1/schemas/wizard_schemas.py | 8 + utils/gui/backend/app.py | 143 + utils/gui/backend/config/__init__.py | 6 + utils/gui/backend/config/defaults.py | 90 + utils/gui/backend/config/logging.py | 57 + utils/gui/backend/config/settings.py | 65 + utils/gui/backend/core/__init__.py | 19 + utils/gui/backend/core/constants.py | 11 + utils/gui/backend/core/exceptions.py | 130 + utils/gui/backend/core/middleware.py | 83 + utils/gui/backend/models/__init__.py | 30 + utils/gui/backend/models/catalog_schemas.py | 212 ++ utils/gui/backend/models/schemas.py | 100 + utils/gui/backend/requirements.txt | 10 + utils/gui/backend/services/__init__.py | 17 + .../services/adapter_policy_service.py | 82 + .../services/catalog_editor_service.py | 289 ++ .../services/catalog_validation_service.py | 171 ++ .../services/config_file_generators.py | 894 +++++++ utils/gui/backend/services/config_service.py | 475 ++++ utils/gui/backend/services/job_store.py | 223 ++ .../services/local_repo_generator_service.py | 87 + .../backend/services/os_package_service.py | 520 ++++ .../services/software_config_service.py | 222 ++ .../gui/backend/services/telemetry_helpers.py | 121 + .../services/wizard_generator_service.py | 212 ++ utils/gui/backend/utils/__init__.py | 14 + utils/gui/backend/utils/file_io.py | 250 ++ utils/gui/frontend/.gitignore | 35 + utils/gui/frontend/index.html | 15 + utils/gui/frontend/package-lock.json | 2360 +++++++++++++++++ utils/gui/frontend/package.json | 32 + utils/gui/frontend/public/index.html | 14 + utils/gui/frontend/src/App.tsx | 49 + utils/gui/frontend/src/assets/omnia-logo.png | Bin 0 -> 33093 bytes utils/gui/frontend/src/components/Button.tsx | 42 + .../frontend/src/components/ErrorBoundary.tsx | 71 + utils/gui/frontend/src/components/Layout.tsx | 244 ++ .../src/components/LoadingSpinner.tsx | 35 + .../gui/frontend/src/components/NotFound.tsx | 15 + .../adapter-policy/AdapterPolicyEditor.tsx | 394 +++ .../components/AddTargetSection.tsx | 32 + .../components/TargetEditSection.tsx | 408 +++ .../components/TargetListSection.tsx | 34 + .../adapter-policy/hooks/useAdapterPolicy.ts | 44 + .../src/features/adapter-policy/index.ts | 1 + .../adapter-policy/schemas/adapterPolicy.ts | 117 + .../features/catalog-editor/CatalogEditor.css | 215 ++ .../features/catalog-editor/CatalogEditor.tsx | 263 ++ .../features/catalog-editor/catalogStore.ts | 59 + .../components/BundleSelector.tsx | 168 ++ .../components/CatalogOverview.tsx | 92 + .../components/DriverPackageEditor.tsx | 313 +++ .../components/EmptyLayerSelector.tsx | 84 + .../components/FunctionalLayerEditor.tsx | 799 ++++++ .../components/InfrastructureEditor.tsx | 694 +++++ .../components/MiscellaneousEditor.tsx | 266 ++ .../components/OSPackageEditor.tsx | 761 ++++++ .../catalog-editor/components/PackageForm.tsx | 335 +++ .../components/RoleSelector.tsx | 108 + .../components/ValidationPanel.tsx | 109 + .../catalog-editor/constants/emptyCatalog.ts | 18 + .../hooks/useBundleSelection.ts | 59 + .../catalog-editor/hooks/useCatalog.ts | 217 ++ .../catalog-editor/hooks/useRoleMappings.ts | 35 + .../src/features/catalog-editor/index.ts | 2 + .../catalog-editor/schemas/catalogSchema.ts | 198 ++ .../utils/cleanCatalogForExport.ts | 150 ++ .../utils/extractErrorMessage.ts | 64 + .../src/features/catalog/CatalogViewer.tsx | 489 ++++ .../configuration-wizard/BmcDiscoveryFlow.tsx | 320 +++ .../ConfigurationWizard.tsx | 112 + .../components/DeploymentOverview.tsx | 134 + .../components/DnsInput.tsx | 51 + .../components/FormField.tsx | 55 + .../configuration-wizard/components/Tabs.tsx | 35 + .../configuration-wizard/configStore.ts | 282 ++ .../configuration-wizard/constants.ts | 21 + .../hooks/useFormErrors.ts | 38 + .../schemas/buildStreamGitLab.ts | 53 + .../schemas/cloudInitConfig.ts | 34 + .../configuration-wizard/schemas/common.ts | 63 + .../schemas/deploymentConfigs.ts | 78 + .../configuration-wizard/schemas/index.ts | 39 + .../schemas/localRepoUserRegistry.ts | 119 + .../schemas/omniaHaDiscoveryConfig.ts | 138 + .../schemas/pxeFunctionalGroups.ts | 42 + .../schemas/storageConfig.ts | 290 ++ .../schemas/telemetryConfigStorage.ts | 321 +++ .../build-stream/BuildStreamGitLabStep.tsx | 258 ++ .../steps/cloud-init/CloudInitCommonTab.tsx | 139 + .../steps/cloud-init/CloudInitConfigStep.tsx | 86 + .../steps/cloud-init/CloudInitGroupsTab.tsx | 199 ++ .../deployment-setup/DeploymentSetupStep.tsx | 376 +++ .../steps/network/DeploymentConfigsStep.tsx | 445 ++++ .../steps/omnia/ConfigSourcesEditor.tsx | 170 ++ .../steps/omnia/K8sClusterRow.tsx | 165 ++ .../steps/omnia/K8sTab.tsx | 153 ++ .../omnia/NodeHardwareDefaultsBuilder.tsx | 85 + .../steps/omnia/OmniaHaDiscoveryStep.tsx | 128 + .../steps/omnia/SlurmClusterRow.tsx | 114 + .../steps/omnia/SlurmTab.tsx | 54 + .../steps/pxe/PxeFunctionalGroupsStep.tsx | 426 +++ .../steps/storage/MountParamsTab.tsx | 160 ++ .../steps/storage/MountsTab.tsx | 330 +++ .../steps/storage/PowerVaultTab.tsx | 325 +++ .../steps/storage/S3Tab.tsx | 57 + .../steps/storage/StorageConfigStep.tsx | 221 ++ .../steps/storage/SwapTab.tsx | 140 + .../steps/summary/SummaryAndGenerateStep.tsx | 390 +++ .../steps/telemetry/BridgesTab.tsx | 385 +++ .../steps/telemetry/DcgmTab.tsx | 17 + .../steps/telemetry/IdracTab.tsx | 173 ++ .../steps/telemetry/LdmsTab.tsx | 155 ++ .../steps/telemetry/OmeTab.tsx | 67 + .../steps/telemetry/PowerscaleTab.tsx | 163 ++ .../steps/telemetry/SinksTab.tsx | 38 + .../telemetry/TelemetryConfigStorageStep.tsx | 473 ++++ .../steps/telemetry/UfmTab.tsx | 172 ++ .../steps/telemetry/VastTab.tsx | 184 ++ .../sinks/KafkaConfigurationSection.tsx | 100 + .../sinks/KafkaStorageResourcesSection.tsx | 79 + .../sinks/VictoriaClusterStorageSection.tsx | 91 + .../VictoriaLogsClusterStorageSection.tsx | 99 + .../telemetry/sinks/VictoriaLogsSection.tsx | 97 + .../sinks/VictoriaMetricsSection.tsx | 97 + .../steps/telemetry/telemetryDefaults.ts | 226 ++ .../configuration-wizard/utils/csvParser.ts | 132 + .../utils/l2Validation.ts | 509 ++++ .../utils/pxeMappingAnalyzer.ts | 169 ++ .../utils/transformConfigSources.ts | 39 + .../utils/transformNodeHardwareDefaults.ts | 35 + .../utils/transformSlurmCluster.ts | 46 + .../features/confirmDialog/ConfirmDialog.tsx | 68 + .../confirmDialog/confirmDialogStore.ts | 44 + .../frontend/src/features/landing/Landing.tsx | 119 + .../LocalRepoConfigForm.tsx | 334 +++ .../LocalRepoManagement.tsx | 144 + .../hooks/useLocalRepo.ts | 8 + .../localRepoDefaults.ts | 17 + .../local-repo-management/localRepoStore.ts | 69 + .../sections/AdditionalReposSection.tsx | 133 + .../sections/CredentialsSection.tsx | 110 + .../sections/OsSubscriptionSection.tsx | 139 + .../sections/UserRegistrySection.tsx | 104 + .../sections/UserRepoSection.tsx | 176 ++ .../tabs/LocalRepoAdditionalReposTab.tsx | 44 + .../tabs/LocalRepoOsOmniaRepoTab.tsx | 130 + .../tabs/LocalRepoOsRepoTab.tsx | 141 + .../tabs/LocalRepoOsSubscriptionTab.tsx | 47 + .../LocalRepoUserRegistryCredentialsTab.tsx | 64 + .../tabs/LocalRepoUserRepoTab.tsx | 47 + .../src/features/overview/Overview.tsx | 141 + .../features/preset-picker/PresetPicker.tsx | 149 ++ .../gui/frontend/src/features/toast/Toast.tsx | 52 + .../src/features/toast/ToastContainer.tsx | 18 + .../frontend/src/features/toast/toastStore.ts | 60 + utils/gui/frontend/src/index.tsx | 11 + utils/gui/frontend/src/styles/index.css | 15 + utils/gui/frontend/src/styles/styles.css | 1768 ++++++++++++ utils/gui/frontend/src/utils/api.ts | 95 + .../gui/frontend/src/utils/hooks/useConfig.ts | 30 + utils/gui/frontend/src/vite-env.d.ts | 9 + utils/gui/frontend/tsconfig.json | 31 + utils/gui/frontend/tsconfig.node.json | 12 + utils/gui/frontend/vite.config.ts | 17 + 180 files changed, 30206 insertions(+) create mode 100644 utils/gui/README.md create mode 100644 utils/gui/backend/.gitignore create mode 100644 utils/gui/backend/__init__.py create mode 100644 utils/gui/backend/api/__init__.py create mode 100644 utils/gui/backend/api/v1/__init__.py create mode 100644 utils/gui/backend/api/v1/dependencies.py create mode 100644 utils/gui/backend/api/v1/routes/__init__.py create mode 100644 utils/gui/backend/api/v1/routes/adapter_policy_routes.py create mode 100644 utils/gui/backend/api/v1/routes/catalog_editor_routes.py create mode 100644 utils/gui/backend/api/v1/routes/catalog_routes.py create mode 100644 utils/gui/backend/api/v1/routes/local_repo_routes.py create mode 100644 utils/gui/backend/api/v1/routes/wizard_routes.py create mode 100644 utils/gui/backend/api/v1/schemas/__init__.py create mode 100644 utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py create mode 100644 utils/gui/backend/api/v1/schemas/wizard_schemas.py create mode 100644 utils/gui/backend/app.py create mode 100644 utils/gui/backend/config/__init__.py create mode 100644 utils/gui/backend/config/defaults.py create mode 100644 utils/gui/backend/config/logging.py create mode 100644 utils/gui/backend/config/settings.py create mode 100644 utils/gui/backend/core/__init__.py create mode 100644 utils/gui/backend/core/constants.py create mode 100644 utils/gui/backend/core/exceptions.py create mode 100644 utils/gui/backend/core/middleware.py create mode 100644 utils/gui/backend/models/__init__.py create mode 100644 utils/gui/backend/models/catalog_schemas.py create mode 100644 utils/gui/backend/models/schemas.py create mode 100644 utils/gui/backend/requirements.txt create mode 100644 utils/gui/backend/services/__init__.py create mode 100644 utils/gui/backend/services/adapter_policy_service.py create mode 100644 utils/gui/backend/services/catalog_editor_service.py create mode 100644 utils/gui/backend/services/catalog_validation_service.py create mode 100644 utils/gui/backend/services/config_file_generators.py create mode 100644 utils/gui/backend/services/config_service.py create mode 100644 utils/gui/backend/services/job_store.py create mode 100644 utils/gui/backend/services/local_repo_generator_service.py create mode 100644 utils/gui/backend/services/os_package_service.py create mode 100644 utils/gui/backend/services/software_config_service.py create mode 100644 utils/gui/backend/services/telemetry_helpers.py create mode 100644 utils/gui/backend/services/wizard_generator_service.py create mode 100644 utils/gui/backend/utils/__init__.py create mode 100644 utils/gui/backend/utils/file_io.py create mode 100644 utils/gui/frontend/.gitignore create mode 100644 utils/gui/frontend/index.html create mode 100644 utils/gui/frontend/package-lock.json create mode 100644 utils/gui/frontend/package.json create mode 100644 utils/gui/frontend/public/index.html create mode 100644 utils/gui/frontend/src/App.tsx create mode 100644 utils/gui/frontend/src/assets/omnia-logo.png create mode 100644 utils/gui/frontend/src/components/Button.tsx create mode 100644 utils/gui/frontend/src/components/ErrorBoundary.tsx create mode 100644 utils/gui/frontend/src/components/Layout.tsx create mode 100644 utils/gui/frontend/src/components/LoadingSpinner.tsx create mode 100644 utils/gui/frontend/src/components/NotFound.tsx create mode 100644 utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx create mode 100644 utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx create mode 100644 utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx create mode 100644 utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx create mode 100644 utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts create mode 100644 utils/gui/frontend/src/features/adapter-policy/index.ts create mode 100644 utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css create mode 100644 utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/catalogStore.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx create mode 100644 utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/index.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts create mode 100644 utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts create mode 100644 utils/gui/frontend/src/features/catalog/CatalogViewer.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/components/FormField.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/configStore.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/constants.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitConfigStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitGroupsTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/deployment-setup/DeploymentSetupStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/network/DeploymentConfigsStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/ConfigSourcesEditor.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/K8sClusterRow.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/K8sTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/NodeHardwareDefaultsBuilder.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/OmniaHaDiscoveryStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/SlurmClusterRow.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/omnia/SlurmTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/pxe/PxeFunctionalGroupsStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/MountParamsTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/MountsTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/PowerVaultTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/S3Tab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/StorageConfigStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/storage/SwapTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/summary/SummaryAndGenerateStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/BridgesTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/DcgmTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/IdracTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/LdmsTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/OmeTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/PowerscaleTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/SinksTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/TelemetryConfigStorageStep.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/UfmTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/VastTab.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/KafkaConfigurationSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/KafkaStorageResourcesSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/VictoriaClusterStorageSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/VictoriaLogsClusterStorageSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/VictoriaLogsSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/sinks/VictoriaMetricsSection.tsx create mode 100644 utils/gui/frontend/src/features/configuration-wizard/steps/telemetry/telemetryDefaults.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/csvParser.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/l2Validation.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/pxeMappingAnalyzer.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/transformConfigSources.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/transformNodeHardwareDefaults.ts create mode 100644 utils/gui/frontend/src/features/configuration-wizard/utils/transformSlurmCluster.ts create mode 100644 utils/gui/frontend/src/features/confirmDialog/ConfirmDialog.tsx create mode 100644 utils/gui/frontend/src/features/confirmDialog/confirmDialogStore.ts create mode 100644 utils/gui/frontend/src/features/landing/Landing.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/LocalRepoConfigForm.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/LocalRepoManagement.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/hooks/useLocalRepo.ts create mode 100644 utils/gui/frontend/src/features/local-repo-management/localRepoDefaults.ts create mode 100644 utils/gui/frontend/src/features/local-repo-management/localRepoStore.ts create mode 100644 utils/gui/frontend/src/features/local-repo-management/sections/AdditionalReposSection.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/sections/CredentialsSection.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/sections/OsSubscriptionSection.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/sections/UserRegistrySection.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/sections/UserRepoSection.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoAdditionalReposTab.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoOsOmniaRepoTab.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoOsRepoTab.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoOsSubscriptionTab.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoUserRegistryCredentialsTab.tsx create mode 100644 utils/gui/frontend/src/features/local-repo-management/tabs/LocalRepoUserRepoTab.tsx create mode 100644 utils/gui/frontend/src/features/overview/Overview.tsx create mode 100644 utils/gui/frontend/src/features/preset-picker/PresetPicker.tsx create mode 100644 utils/gui/frontend/src/features/toast/Toast.tsx create mode 100644 utils/gui/frontend/src/features/toast/ToastContainer.tsx create mode 100644 utils/gui/frontend/src/features/toast/toastStore.ts create mode 100644 utils/gui/frontend/src/index.tsx create mode 100644 utils/gui/frontend/src/styles/index.css create mode 100644 utils/gui/frontend/src/styles/styles.css create mode 100644 utils/gui/frontend/src/utils/api.ts create mode 100644 utils/gui/frontend/src/utils/hooks/useConfig.ts create mode 100644 utils/gui/frontend/src/vite-env.d.ts create mode 100644 utils/gui/frontend/tsconfig.json create mode 100644 utils/gui/frontend/tsconfig.node.json create mode 100644 utils/gui/frontend/vite.config.ts diff --git a/utils/gui/README.md b/utils/gui/README.md new file mode 100644 index 0000000000..2fecdc4117 --- /dev/null +++ b/utils/gui/README.md @@ -0,0 +1,392 @@ +# Omnia GUI Module + +Web-based interface for Omnia configuration management, providing comprehensive tools for catalog editing, configuration wizard, and adapter policy transformations. + +## Overview + +The GUI Module provides a modern web-based interface for managing Omnia configurations. It consists of three main sub-modules: + +- **Catalog Editor GUI**: Visual editor for Omnia catalog JSON files with real-time validation +- **Configuration Wizard**: Step-by-step wizard for deployment configuration with conditional per-step tabs +- **Adapter Policy Module**: Interface for adapter policy transformations + +## Architecture + +The GUI Module follows a modern web architecture with clear separation of concerns: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Catalog │ │ Configuration│ │ Adapter │ │ +│ │ Editor │ │ Wizard │ │ Policy │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Zustand State Management + TanStack Query │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Backend (FastAPI) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Catalog │ │ Configuration│ │ Adapter │ │ +│ │ Service │ │ Service │ │ Service │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ In-Memory Catalog + Job Store │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +gui/ +├── backend/ # Backend FastAPI application +│ ├── app.py # FastAPI application entry point +│ ├── api/v1/ # API endpoints +│ │ ├── routes/ # Route handlers +│ │ │ ├── catalog_routes.py +│ │ │ ├── catalog_editor_routes.py +│ │ │ ├── wizard_routes.py +│ │ │ ├── local_repo_routes.py +│ │ │ └── adapter_policy_routes.py +│ │ ├── schemas/ # Pydantic schemas +│ │ └── dependencies.py # Dependency injection / service providers +│ ├── config/ # Settings and logging +│ ├── core/ # Middleware and exception handling +│ ├── models/ # Data models +│ ├── services/ # Business logic +│ └── utils/ # Utilities +├── frontend/ # Frontend React + Vite application +│ ├── src/ +│ │ ├── features/ # Feature-based modules +│ │ │ ├── catalog-editor/ +│ │ │ ├── catalog/ +│ │ │ ├── configuration-wizard/ +│ │ │ ├── adapter-policy/ +│ │ │ ├── local-repo-management/ +│ │ │ ├── preset-picker/ +│ │ │ ├── landing/ +│ │ │ ├── overview/ +│ │ │ ├── toast/ +│ │ │ └── confirmDialog/ +│ │ ├── components/ # Shared components +│ │ └── hooks/ # Shared hooks +│ ├── package.json +│ └── vite.config.ts +└── out/ # Generated output files directory +``` + +## Features + +### Catalog Editor GUI + +- **Visual Catalog Management**: Web-based CRUD for catalog packages and layers +- **Real-time Validation**: Immediate feedback with L1/L2 validation +- **In-Memory Editing**: Edit catalog in memory, save on demand +- **Preset Picker**: Load catalog templates from repository +- **Auto-Populate**: Automatically populate functional layers from roles +- **State Persistence**: Maintain edits across page refreshes +- **Bundle Selector**: Select packages from predefined bundles +- **Import/Export**: Load and save catalog JSON files + +### Configuration Wizard + +- **Step-by-Step Configuration**: 9 main steps for comprehensive configuration +- **Conditional Tabs**: Per-step tabs for detailed configuration (e.g., 9 telemetry source/sink tabs) +- **PXE Mapping**: PXE mapping file upload and editing +- **Config File Generation**: Automatic generation of YAML configuration files +- **State Persistence**: Maintain wizard data across navigation +- **Job Queue**: Async operation management with job tracking + +### Adapter Policy Module + +- **Policy Transformation**: Transform catalog data to adapter policy format +- **Field Mapping**: Map catalog fields to adapter policy fields +- **Filtering**: Filter packages by allowlist or functional layers +- **Deduplication**: Remove duplicate packages +- **Validation**: Validate adapter policy configuration + +## Backend Setup + +### Prerequisites + +- Python 3.12+ +- FastAPI +- Pydantic +- Python dependencies listed in `backend/requirements.txt` + +### Installation + +```bash +cd utils/gui +pip install -r backend/requirements.txt +``` + +Dependencies are in `utils/gui/backend/requirements.txt`. + +### Configuration + +Configuration is managed through environment variables and settings in `backend/config/settings.py`. Key environment variables include: + +- `API_TITLE`: API title +- `API_DESCRIPTION`: API description +- `API_VERSION`: API version +- `API_PREFIX`: API endpoint prefix (default: `/api/v1`) +- `HOST`: Server host (default: `0.0.0.0`) +- `PORT`: Server port (default: `8000`) +- `RELOAD`: Auto-reload in development (default: `true`) +- `LOG_LEVEL`: Logging level (default: `info`) +- `CORS_ORIGINS`: Comma-separated allowed CORS origins +- `CORS_ALLOW_CREDENTIALS`: Allow credentials (default: `true`) +- `CORS_ALLOW_METHODS`: Allowed HTTP methods +- `CORS_ALLOW_HEADERS`: Allowed HTTP headers +- `ENVIRONMENT`: Environment name (default: `development`) +- `DEBUG`: Debug mode (default: `true`) + +Output paths are derived from the repository layout (`utils/gui/out`), and base input files are loaded from `examples/` and the repository `input/` directory. + +### Running the Backend + +```bash +cd utils/gui +python -m backend.app +``` + +or from the repository root: + +```bash +python -m utils.gui.backend.app +``` + +The backend will start on `http://localhost:8000` + +API documentation available at: +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Frontend Setup + +### Prerequisites + +- Node.js 18+ +- npm or yarn +- Modern web browser + +### Installation + +```bash +cd frontend +npm install +``` + +### Development + +```bash +npm start +``` + +The Vite dev server will start on `http://localhost:3000` + +### Build for Production + +```bash +npm run build +``` + +## API Documentation + +### Catalog Endpoints (prefix: `/api/v1/catalog`) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| POST | `/api/v1/catalog/import` | Import catalog from JSON | +| POST | `/api/v1/catalog/validate` | Validate catalog against schema | +| GET | `/api/v1/catalog/presets` | Get list of available presets | +| GET | `/api/v1/catalog/presets/{filename}` | Load specific preset | +| POST | `/api/v1/catalog/packages/functional` | Add functional package | +| PUT | `/api/v1/catalog/packages/functional/{id}` | Update functional package | +| DELETE | `/api/v1/catalog/packages/functional/{id}` | Delete functional package | +| POST | `/api/v1/catalog/packages/os` | Add OS package | +| PUT | `/api/v1/catalog/packages/os/{id}` | Update OS package | +| DELETE | `/api/v1/catalog/packages/os/{id}` | Delete OS package | +| POST | `/api/v1/catalog/packages/infrastructure` | Add infrastructure package | +| PUT | `/api/v1/catalog/packages/infrastructure/{id}` | Update infrastructure package | +| DELETE | `/api/v1/catalog/packages/infrastructure/{id}` | Delete infrastructure package | +| POST | `/api/v1/catalog/packages/driver` | Add driver package | +| PUT | `/api/v1/catalog/packages/driver/{id}` | Update driver package | +| DELETE | `/api/v1/catalog/packages/driver/{id}` | Delete driver package | +| POST | `/api/v1/catalog/packages/miscellaneous` | Add miscellaneous package | +| PUT | `/api/v1/catalog/packages/miscellaneous/{id}` | Update miscellaneous package | +| DELETE | `/api/v1/catalog/packages/miscellaneous/{id}` | Delete miscellaneous package | +| POST | `/api/v1/catalog/layers` | Add functional layer | +| PUT | `/api/v1/catalog/layers/{layerName}` | Update functional layer | +| DELETE | `/api/v1/catalog/layers/{layerName}` | Delete functional layer | + +### Catalog Editor Endpoints (prefix: `/api/v1/catalog-editor`) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/api/v1/catalog-editor/os-packages/bundles` | Get available OS bundles | +| GET | `/api/v1/catalog-editor/os-packages/bundle/{bundleName}` | Get packages in OS bundle | +| GET | `/api/v1/catalog-editor/roles` | Get available roles | +| GET | `/api/v1/catalog-editor/roles/{role}/packages` | Get packages for role | + +### Wizard Endpoints (prefix: `/api/v1/config`) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| POST | `/api/v1/config/generate-all` | Generate all configuration files | +| GET | `/api/v1/config/generate-all/{job_id}` | Get generation job status | +| POST | `/api/v1/config/download-files` | Download generated files | + +### Local Repository Endpoints (prefix: `/api/v1/local-repo`) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| POST | `/api/v1/local-repo/generate` | Generate local repository config files | + +### Adapter Policy Endpoints (prefix: `/api/v1`) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/api/v1/adapter-policy` | Get current adapter policy | +| POST | `/api/v1/adapter-policy` | Save adapter policy | +| DELETE | `/api/v1/adapter-policy` | Delete custom adapter policy | + +## Configuration Wizard Steps + +The Configuration Wizard consists of 9 main steps: + +1. **Deployment Setup**: Select cluster type and configure optional features (Cloud-Init, BMC Discovery, Telemetry, Build Stream, GitLab) +2. **PXE Functional Groups**: Configure PXE boot mapping, functional groups, DHCP settings, DNS, and kernel overrides +3. **Network Configuration**: Configure admin and InfiniBand networks, subnets, IP addresses, DNS, and NTP servers +4. **Storage Configuration**: Configure storage mounts, mount profiles, PowerVault iSCSI, swap files, and S3 storage backend +5. **Cloud-Init Configuration**: Configure additional cloud-init `write_files` and `runcmd` +6. **Omnia Cluster Configuration**: Configure Slurm clusters, Kubernetes service clusters, high availability, and security +7. **Telemetry Configuration**: Configure telemetry sources, bridges, sinks, and storage resources +8. **Build Stream Configuration**: Configure BuildStream host and GitLab integration +9. **Summary & Generate**: Review configuration and generate deployment files + +### Telemetry Configuration Tabs + +The **Telemetry Configuration** step includes tabs for: +- **Sources**: iDRAC, LDMS, DCGM, PowerScale, UFM, VAST, OME +- **Bridges** +- **Sinks** + +Storage resource fields are configured within the relevant source and sink tabs. + +## Adapter Policy Transformation + +The Adapter Policy Module transforms catalog data to adapter policy format based on rules defined in `adapter_policy_default.json`. + +### Supported Transformations + +- **Field Transformations**: Exclude fields, rename fields +- **Filter Types**: Allowlist filtering, substring filtering +- **Pull Operations**: Pull packages from source files +- **Derived Operations**: Extract common packages, deduplication + +### Target Files + +- `default_packages.json`: Default OS packages +- `admin_debug_packages.json`: Admin and debug tools +- `openldap.json`: LDAP authentication packages +- `ldms.json`: LDMS monitoring packages +- `ucx.json`: UCX communication library +- `openmpi.json`: OpenMPI packages +- `service_k8s.json`: Kubernetes service packages +- `slurm_custom.json`: Slurm workload manager packages +- `additional_packages.json`: Additional packages from miscellaneous +- `csi_driver_powerscale.json`: CSI driver for PowerScale + +## Development + +### Backend Development + +The backend uses FastAPI with the following structure: + +- **Routes**: API endpoint definitions in `api/v1/routes/` +- **Services**: Business logic in `services/` +- **Models**: Data models in `models/` +- **Schemas**: API schemas in `api/v1/schemas/` +- **Core**: Core functionality in `core/` + +### Frontend Development + +The frontend uses React with the following structure: + +- **Features**: Feature-based modules in `src/features/` +- **Components**: Shared components in `src/components/` +- **Hooks**: Custom React hooks in feature directories +- **Stores**: Zustand state management +- **Schemas**: TypeScript/Zod schemas + +### State Management + +- **Client State**: Zustand with persist middleware +- **Server State**: TanStack Query for API calls +- **Form State**: React Hook Form with Zod validation + +## Deployment + +### Backend Deployment + +```bash +gunicorn utils.gui.backend.app:app -w 4 -k uvicorn.workers.UvicornWorker +``` + +### Frontend Deployment + +```bash +cd frontend +npm run build +# Serve the dist/ directory with nginx or FastAPI static files +``` + +### Environment Variables + +Set the following environment variables: + +- `API_TITLE`: API title +- `API_DESCRIPTION`: API description +- `API_VERSION`: API version +- `API_PREFIX`: API endpoint prefix (default: `/api/v1`) +- `HOST`: Server host (default: `0.0.0.0`) +- `PORT`: Server port (default: `8000`) +- `RELOAD`: Auto-reload in development (default: `true`) +- `LOG_LEVEL`: Logging level (default: `info`) +- `CORS_ORIGINS`: Comma-separated allowed CORS origins +- `CORS_ALLOW_CREDENTIALS`: Allow credentials (default: `true`) +- `CORS_ALLOW_METHODS`: Allowed HTTP methods +- `CORS_ALLOW_HEADERS`: Allowed HTTP headers +- `ENVIRONMENT`: Environment name (default: `development`) +- `DEBUG`: Debug mode (default: `true`) + +Output paths are derived from the repository layout (`utils/gui/out`), and catalog examples are loaded from `examples/`. + +## Troubleshooting + +### Common Issues + +1. **Backend not starting**: Check Python version and dependencies +2. **Frontend not connecting**: Verify backend URL and CORS settings +3. **Catalog not persisting**: Check JobStore and app.state.catalog initialization +4. **Wizard state lost**: Check localStorage and Zustand persist configuration + +### Logs + +Backend logs are configured in `backend/config/logging.py` and output to console. + +Frontend logs are available in browser developer tools console. + +## Documentation + +- **Adapter Policy Guide**: `../../build_stream/core/catalog/ADAPTER_POLICY_GUIDE.md` + +## License + +Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. diff --git a/utils/gui/backend/.gitignore b/utils/gui/backend/.gitignore new file mode 100644 index 0000000000..2da54e473c --- /dev/null +++ b/utils/gui/backend/.gitignore @@ -0,0 +1,75 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/utils/gui/backend/__init__.py b/utils/gui/backend/__init__.py new file mode 100644 index 0000000000..677f294684 --- /dev/null +++ b/utils/gui/backend/__init__.py @@ -0,0 +1,10 @@ +""" +Config Editor Module (Refactored) + +Production-ready configuration editor with proper structure, +dependency injection, and error handling. +""" + +from .app import app + +__all__ = ["app"] diff --git a/utils/gui/backend/api/__init__.py b/utils/gui/backend/api/__init__.py new file mode 100644 index 0000000000..24b043b8a4 --- /dev/null +++ b/utils/gui/backend/api/__init__.py @@ -0,0 +1 @@ +"""API module.""" diff --git a/utils/gui/backend/api/v1/__init__.py b/utils/gui/backend/api/v1/__init__.py new file mode 100644 index 0000000000..954352d0e9 --- /dev/null +++ b/utils/gui/backend/api/v1/__init__.py @@ -0,0 +1,25 @@ +"""API v1 module.""" + +from .dependencies import ( + get_settings_dependency, + get_config_service, + get_adapter_policy_service, + get_catalog_editor_service, + get_wizard_generator_service, + get_local_repo_generator_service, + get_os_package_service, + get_software_config_service, + reset_test_settings +) + +__all__ = [ + "get_settings_dependency", + "get_config_service", + "get_adapter_policy_service", + "get_catalog_editor_service", + "get_wizard_generator_service", + "get_local_repo_generator_service", + "get_os_package_service", + "get_software_config_service", + "reset_test_settings" +] diff --git a/utils/gui/backend/api/v1/dependencies.py b/utils/gui/backend/api/v1/dependencies.py new file mode 100644 index 0000000000..4af9f012ca --- /dev/null +++ b/utils/gui/backend/api/v1/dependencies.py @@ -0,0 +1,132 @@ +""" +Dependency injection setup for Config Editor Module + +Provides FastAPI Depends functions for service injection following best practices. +""" + +from fastapi import Depends, Request + +from ...config.settings import Settings, get_settings +from ...services.config_service import ConfigService +from ...services.catalog_editor_service import CatalogEditorService +from ...services.adapter_policy_service import AdapterPolicyService +from ...services.wizard_generator_service import WizardGeneratorService +from ...services.local_repo_generator_service import LocalRepoGeneratorService +from ...services.os_package_service import OSPackageService +from ...services.software_config_service import SoftwareConfigService + + +# Settings dependency +def get_settings_dependency() -> Settings: + """FastAPI dependency for settings.""" + return get_settings() + + +# Service dependencies +def get_config_service( + settings: Settings = Depends(get_settings_dependency) +) -> ConfigService: + """FastAPI dependency for ConfigService. + + Args: + settings: Application settings + + Returns: + ConfigService instance + """ + return ConfigService(settings=settings) + + +def get_adapter_policy_service( + settings: Settings = Depends(get_settings_dependency) +) -> AdapterPolicyService: + """FastAPI dependency for AdapterPolicyService. + + Args: + settings: Application settings + + Returns: + AdapterPolicyService instance + """ + return AdapterPolicyService(settings=settings) + + +def get_catalog_editor_service(request: Request) -> CatalogEditorService: + """FastAPI dependency for CatalogEditorService. + + Args: + request: FastAPI Request object for accessing app.state + + Returns: + CatalogEditorService instance + """ + return CatalogEditorService(app_state=request.app.state) + + +def get_wizard_generator_service( + settings: Settings = Depends(get_settings_dependency) +) -> WizardGeneratorService: + """FastAPI dependency for WizardGeneratorService. + + Args: + settings: Application settings + + Returns: + WizardGeneratorService instance + """ + return WizardGeneratorService(settings=settings) + + +def get_local_repo_generator_service( + settings: Settings = Depends(get_settings_dependency) +) -> LocalRepoGeneratorService: + """FastAPI dependency for LocalRepoGeneratorService. + + Args: + settings: Application settings + + Returns: + LocalRepoGeneratorService instance + """ + return LocalRepoGeneratorService(settings=settings) + + +def get_os_package_service(request: Request) -> OSPackageService: + """FastAPI dependency for OSPackageService. + + Args: + request: FastAPI Request object for accessing app.state + + Returns: + OSPackageService instance (cached in app.state) + """ + if not hasattr(request.app.state, 'os_package_service'): + settings = get_settings() + request.app.state.os_package_service = OSPackageService( + config_dir=str(settings.base_input_dir / "config") + ) + return request.app.state.os_package_service + + +def get_software_config_service(request: Request) -> SoftwareConfigService: + """FastAPI dependency for SoftwareConfigService. + + Args: + request: FastAPI Request object for accessing app.state + + Returns: + SoftwareConfigService instance (cached in app.state) + """ + if not hasattr(request.app.state, 'software_config_service'): + settings = get_settings() + request.app.state.software_config_service = SoftwareConfigService( + config_dir=str(settings.base_input_dir) + ) + return request.app.state.software_config_service + + +# Testing utilities +def reset_test_settings() -> None: + """Reset settings singleton (for testing only).""" + from ...config.settings import reset_settings + reset_settings() diff --git a/utils/gui/backend/api/v1/routes/__init__.py b/utils/gui/backend/api/v1/routes/__init__.py new file mode 100644 index 0000000000..2fa39a8359 --- /dev/null +++ b/utils/gui/backend/api/v1/routes/__init__.py @@ -0,0 +1,15 @@ +"""API v1 routes module.""" + +from .catalog_routes import router as catalog_router +from .wizard_routes import router as wizard_router +from .adapter_policy_routes import router as adapter_policy_router +from .catalog_editor_routes import router as catalog_editor_router +from .local_repo_routes import router as local_repo_router + +__all__ = [ + "catalog_router", + "wizard_router", + "adapter_policy_router", + "catalog_editor_router", + "local_repo_router" +] diff --git a/utils/gui/backend/api/v1/routes/adapter_policy_routes.py b/utils/gui/backend/api/v1/routes/adapter_policy_routes.py new file mode 100644 index 0000000000..c585520d85 --- /dev/null +++ b/utils/gui/backend/api/v1/routes/adapter_policy_routes.py @@ -0,0 +1,61 @@ +""" +Adapter Policy routes for Config Editor Module + +Provides API endpoints for adapter policy management. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException +from typing import Dict, Any + +from ....services.adapter_policy_service import AdapterPolicyService +from ..dependencies import get_adapter_policy_service +from ....core.exceptions import AdapterPolicyNotFoundError + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# Adapter Policy Endpoints +@router.get("/adapter-policy") +async def get_adapter_policy( + service: AdapterPolicyService = Depends(get_adapter_policy_service) +) -> Dict[str, Any]: + """Get the current adapter policy (custom or default).""" + try: + result = service.get_adapter_policy() + return result + except AdapterPolicyNotFoundError as e: + logger.error("Adapter policy not found: %s", e.policy_type) + raise HTTPException(404, "Adapter policy not found") + except Exception: + logger.exception("Failed to get adapter policy") + raise HTTPException(500, "Failed to load adapter policy") + + +@router.post("/adapter-policy") +async def save_adapter_policy( + policy: Dict[str, Any], + service: AdapterPolicyService = Depends(get_adapter_policy_service) +) -> Dict[str, str]: + """Save adapter policy to custom policy file.""" + try: + service.save_adapter_policy(policy) + return {"status": "success", "message": "Adapter policy saved successfully"} + except (OSError, ValueError): + logger.exception("Failed to save adapter policy") + raise HTTPException(500, "Failed to save adapter policy") + + +@router.delete("/adapter-policy") +async def delete_adapter_policy( + service: AdapterPolicyService = Depends(get_adapter_policy_service) +) -> Dict[str, str]: + """Delete custom adapter policy (reverts to default).""" + try: + service.delete_adapter_policy() + return {"status": "success", "message": "Custom adapter policy deleted"} + except (OSError, ValueError): + logger.exception("Failed to delete adapter policy") + raise HTTPException(500, "Failed to delete adapter policy") + diff --git a/utils/gui/backend/api/v1/routes/catalog_editor_routes.py b/utils/gui/backend/api/v1/routes/catalog_editor_routes.py new file mode 100644 index 0000000000..4b1060845a --- /dev/null +++ b/utils/gui/backend/api/v1/routes/catalog_editor_routes.py @@ -0,0 +1,200 @@ +"""FastAPI routes for catalog editor - OS package templates and role mappings.""" + +import json +import logging +from fastapi import APIRouter, Depends, Query + +from ....api.v1.schemas.catalog_editor_schemas import ( + BundleListResponse, + BundleInfo, +) +from ....services.os_package_service import OSPackageService +from ....services.software_config_service import SoftwareConfigService +from ..dependencies import get_os_package_service, get_software_config_service + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/catalog-editor", tags=["Catalog Editor"]) + + +@router.get("/os-packages/bundles", response_model=BundleListResponse) +async def list_os_bundles( + arch: str = Query(...), + os_family: str = Query(...), + version: str = Query(...), + os_package_service: OSPackageService = Depends(get_os_package_service) +): + """List all available bundles for a given OS/arch/version. + + Returns bundle names, types (functional/infra/os), and package counts. + + Example: + GET /api/v1/catalog-editor/os-packages/bundles?arch=x86_64&os_family=rhel&version=10.0 + """ + bundles = os_package_service.list_available_bundles(arch, os_family, version) + + bundle_infos = [ + BundleInfo(**bundle) for bundle in bundles + ] + + return BundleListResponse(bundles=bundle_infos) + + +@router.get("/os-packages/bundle/{bundle_name}") +async def get_bundle_packages( + bundle_name: str, + arch: str = Query(...), + os_family: str = Query(...), + version: str = Query(...), + os_package_service: OSPackageService = Depends(get_os_package_service) +): + """Get packages from a specific bundle. + + Returns packages organized by section (e.g., slurm_control_node, slurm_node). + + Example: + GET /api/v1/catalog-editor/os-packages/bundle/slurm_custom?arch=x86_64&os_family=rhel&version=10.0 + """ + packages = os_package_service.get_bundle_packages(arch, os_family, version, bundle_name) + + return {"bundle_name": bundle_name, "packages": packages} + + +# ─── Role/Bundle Mappings ───────────────────────────────────── + +@router.get("/roles") +async def list_roles(): + """List all available roles with architecture suffixes. + + Returns the 12 predefined functional roles with architecture suffixes. + + Example: + GET /api/v1/catalog-editor/roles + """ + # Return the 11 predefined roles (service_kube_control_plane_first_x86_64 is not used in catalogs) + roles = [ + 'os_x86_64', + 'os_aarch64', + 'service_kube_control_plane_x86_64', + 'service_kube_node_x86_64', + 'slurm_control_node_x86_64', + 'slurm_node_x86_64', + 'slurm_node_aarch64', + 'login_node_x86_64', + 'login_node_aarch64', + 'login_compiler_node_x86_64', + 'login_compiler_node_aarch64', + ] + + return {"roles": sorted(roles)} + + +@router.get("/roles/{role}/packages") +async def get_role_packages( + role: str, + arch: str = Query(...), + os_family: str = Query(...), + version: str = Query(...), + os_package_service: OSPackageService = Depends(get_os_package_service), + sw_config_service: SoftwareConfigService = Depends(get_software_config_service), +): + """Get packages for a specific role. + + Strips architecture suffix from role name to match software_config.json format. + Returns only packages from the specific role section within the bundle. + """ + predefined_bundles = { + 'os_x86_64': ['ldms'], + 'os_aarch64': ['ldms'] + } + + if role in predefined_bundles: + return _get_predefined_role_packages( + role, arch, os_family, version, predefined_bundles[role], os_package_service + ) + + role_name = role.replace('_x86_64', '').replace('_aarch64', '') + bundles = sw_config_service.get_role_bundles(role_name) + logger.info("Role: %s, Bundles: %s", role_name, bundles) + + if not bundles: + return {"role": role, "packages": {}} + + all_packages = {} + for bundle_name in bundles: + metadata = sw_config_service.get_bundle_metadata(bundle_name) + bundle_arch = metadata.get('arch') + + if bundle_arch and arch not in bundle_arch: + logger.info("Skipping bundle %s: arch %s not in %s", bundle_name, arch, bundle_arch) + continue + + version_suffix = f"_v{metadata['version']}" if metadata.get('version') else '' + actual_bundle_name = f"{bundle_name}{version_suffix}" + + packages = _load_bundle_packages( + os_package_service, arch, os_family, version, actual_bundle_name, bundle_name + ) + if packages: + _merge_bundle_sections(all_packages, packages, bundle_name, role_name) + + return {"role": role, "packages": all_packages} + + +def _get_predefined_role_packages( + role: str, + arch: str, + os_family: str, + version: str, + bundle_names: list[str], + os_package_service: OSPackageService +) -> dict: + """Get packages for predefined roles that don't have software_config.json entries.""" + all_packages = {} + for bundle_name in bundle_names: + try: + bundle_packages = os_package_service.get_bundle_packages(arch, os_family, version, bundle_name) + if bundle_name in bundle_packages: + all_packages[bundle_name] = [ + {**pkg, 'architecture': [arch]} for pkg in bundle_packages[bundle_name] + ] + except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError) as exc: + logger.error("Failed to load bundle %s: %s", bundle_name, exc) + continue + return {"role": role, "packages": all_packages} + + +def _load_bundle_packages( + os_package_service: OSPackageService, + arch: str, + os_family: str, + version: str, + actual_bundle_name: str, + base_bundle_name: str +) -> dict: + """Try loading versioned bundle; fall back to base name if not found.""" + for bundle_name in (actual_bundle_name, base_bundle_name): + try: + return os_package_service.get_bundle_packages(arch, os_family, version, bundle_name) + except FileNotFoundError: + logger.info("Bundle not found: %s", bundle_name) + except (json.JSONDecodeError, OSError, ValueError) as exc: + logger.error("Failed to load bundle %s: %s", bundle_name, exc) + return {} + + +def _merge_bundle_sections( + all_packages: dict, + bundle_packages: dict, + bundle_name: str, + role_name: str +) -> None: + """Merge common and role-specific sections from bundle packages.""" + if bundle_name in bundle_packages: + all_packages[bundle_name] = bundle_packages[bundle_name] + logger.info("Found common section %s", bundle_name) + if role_name in bundle_packages and role_name != bundle_name: + if bundle_name in all_packages: + all_packages[bundle_name] = all_packages[bundle_name] + bundle_packages[role_name] + else: + all_packages[role_name] = bundle_packages[role_name] + logger.info("Found role section %s in bundle %s", role_name, bundle_name) diff --git a/utils/gui/backend/api/v1/routes/catalog_routes.py b/utils/gui/backend/api/v1/routes/catalog_routes.py new file mode 100644 index 0000000000..8d28a16278 --- /dev/null +++ b/utils/gui/backend/api/v1/routes/catalog_routes.py @@ -0,0 +1,399 @@ +""" +Catalog Editor routes for Config Editor Module + +Provides API endpoints for direct catalog CRUD operations. +""" + +import logging +from urllib.parse import unquote +from fastapi import APIRouter, Depends, HTTPException, Request +from typing import Dict, Any, List + +from ..dependencies import get_catalog_editor_service +from ....services.catalog_editor_service import CatalogEditorService +from ....models.catalog_schemas import ( + CatalogRoot, + FunctionalPackage, + InfrastructurePackage, + FunctionalLayer, + DriverPackage, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/catalog", tags=["catalog"]) + + +# ─── Catalog Presets (Examples) ─────────────────────────────────────────── + +@router.get("/presets") +async def get_catalog_presets( + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> List[Dict[str, str]]: + """Get list of available catalog preset files.""" + return service.list_catalog_presets() + + +@router.get("/presets/{filename}") +async def get_catalog_preset( + filename: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> Dict[str, Any]: + """Load a specific catalog preset file.""" + try: + return service.load_catalog_preset(filename) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Catalog preset not found: {filename}") + + +@router.post("/validate") +async def validate_catalog( + catalog: CatalogRoot, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Validate catalog without saving.""" + return service.validate_catalog(catalog) + + +# ─── Functional Packages ──────────────────────────────────── + +@router.post("/packages/functional") +async def add_functional_package( + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add a functional package (in-memory, no disk save).""" + catalog = service.get_catalog() + package_id = service.generate_package_id( + pkg.Name, "functional" + ) + catalog.Catalog.FunctionalPackages[package_id] = pkg + logger.info("Added functional package %s, total packages: %s", package_id, len(catalog.Catalog.FunctionalPackages)) + return {"package_id": package_id, "package": pkg} + + +@router.put("/packages/functional/{package_id:path}") +async def update_functional_package( + package_id: str, + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> FunctionalPackage: + """Update a functional package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.FunctionalPackages: + raise HTTPException( + status_code=404, detail="Package not found" + ) + catalog.Catalog.FunctionalPackages[package_id] = pkg + return pkg + + +@router.delete("/packages/functional/{package_id:path}") +async def delete_functional_package( + package_id: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete a functional package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.FunctionalPackages: + raise HTTPException( + status_code=404, detail="Package not found" + ) + del catalog.Catalog.FunctionalPackages[package_id] + return {"message": "Package deleted"} + + +# ─── OS Packages ──────────────────────────────────────────── + +@router.post("/packages/os") +async def add_os_package( + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add an OS package (in-memory, no disk save).""" + catalog = service.get_catalog() + package_id = service.generate_package_id(pkg.Name, "os") + catalog.Catalog.OSPackages[package_id] = pkg + # Don't save to disk - only save on explicit user action + return {"package_id": package_id, "package": pkg} + + +@router.put("/packages/os/{package_id:path}") +async def update_os_package( + package_id: str, + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> FunctionalPackage: + """Update an OS package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.OSPackages: + raise HTTPException( + status_code=404, detail="OS package not found" + ) + catalog.Catalog.OSPackages[package_id] = pkg + return pkg + + +@router.delete("/packages/os/{package_id:path}") +async def delete_os_package( + package_id: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete an OS package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.OSPackages: + raise HTTPException( + status_code=404, detail="OS package not found" + ) + del catalog.Catalog.OSPackages[package_id] + return {"message": "OS package deleted"} + + +# ─── Infrastructure Packages ──────────────────────────────── + +@router.post("/packages/infrastructure") +async def add_infrastructure_package( + pkg: InfrastructurePackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add an infrastructure package (in-memory, no disk save).""" + catalog = service.get_catalog() + package_id = service.generate_package_id( + pkg.Name, "infrastructure" + ) + catalog.Catalog.InfrastructurePackages[package_id] = pkg + # Don't save to disk - only save on explicit user action + return {"package_id": package_id, "package": pkg} + + +@router.put("/packages/infrastructure/{package_id:path}") +async def update_infrastructure_package( + package_id: str, + pkg: InfrastructurePackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> InfrastructurePackage: + """Update an infrastructure package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.InfrastructurePackages: + raise HTTPException( + status_code=404, + detail="Infrastructure package not found", + ) + catalog.Catalog.InfrastructurePackages[package_id] = pkg + return pkg + + +@router.delete("/packages/infrastructure/{package_id:path}") +async def delete_infrastructure_package( + package_id: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete an infrastructure package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.InfrastructurePackages: + raise HTTPException( + status_code=404, + detail="Infrastructure package not found", + ) + del catalog.Catalog.InfrastructurePackages[package_id] + return {"message": "Infrastructure package deleted"} + + +# ─── Functional Layers ────────────────────────────────────── + +@router.post("/layers") +async def add_functional_layer( + layer: FunctionalLayer, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add a functional layer (in-memory, no disk save).""" + catalog = service.get_catalog() + existing_names = [ + l.Name for l in catalog.Catalog.FunctionalLayer + ] + if layer.Name in existing_names: + raise HTTPException( + status_code=400, + detail="Layer name already exists", + ) + catalog.Catalog.FunctionalLayer.append(layer) + # Don't save to disk - only save on explicit user action + return {"message": "Layer added", "layer": layer} + + +@router.put("/layers/{layer_name:path}") +async def update_functional_layer( + layer_name: str, + layer: FunctionalLayer, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> FunctionalLayer: + """Update a functional layer (in-memory, no disk save).""" + layer_name = unquote(layer_name) + + catalog = service.get_catalog() + for i, existing in enumerate( + catalog.Catalog.FunctionalLayer + ): + if existing.Name == layer_name: + catalog.Catalog.FunctionalLayer[i] = layer + return layer + # Layer doesn't exist — upsert: create it + catalog.Catalog.FunctionalLayer.append(layer) + logger.info("Layer %s not found, created via upsert", layer_name) + return layer + + +@router.delete("/layers/{layer_name:path}") +async def delete_functional_layer( + layer_name: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete a functional layer (in-memory, no disk save).""" + layer_name = unquote(layer_name) + + catalog = service.get_catalog() + original_count = len(catalog.Catalog.FunctionalLayer) + catalog.Catalog.FunctionalLayer = [ + l + for l in catalog.Catalog.FunctionalLayer + if l.Name != layer_name + ] + if len(catalog.Catalog.FunctionalLayer) == original_count: + raise HTTPException(status_code=404, detail="Layer not found") + return {"message": "Layer deleted"} + + +# ─── Import / Export ──────────────────────────────────────── + +@router.post("/import") +async def import_catalog( + catalog: CatalogRoot, + request: Request, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> CatalogRoot: + """Import a catalog from JSON (in-memory only, no disk save).""" + validation = service.validate_catalog(catalog) + if not validation["valid"]: + raise HTTPException( + status_code=422, detail=validation["errors"] + ) + # Load catalog into memory only (no disk save) + service.set_catalog(catalog) + return catalog + + +# ─── Miscellaneous Packages ────────────────────────────────── + +@router.post("/packages/miscellaneous") +async def add_miscellaneous_package( + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add a miscellaneous package (in-memory, no disk save).""" + catalog = service.get_catalog() + package_id = service.generate_package_id(pkg.Name, "miscellaneous") + if package_id in catalog.Catalog.FunctionalPackages: + raise HTTPException(status_code=400, detail="Miscellaneous package ID already exists") + catalog.Catalog.FunctionalPackages[package_id] = pkg + catalog.Catalog.Miscellaneous.append(package_id) + logger.info("Added miscellaneous package %s, total packages: %s", package_id, len(catalog.Catalog.FunctionalPackages)) + return {"package_id": package_id, "package": pkg} + + +@router.put("/packages/miscellaneous/{package_id:path}") +async def update_miscellaneous_package( + package_id: str, + pkg: FunctionalPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> FunctionalPackage: + """Update a miscellaneous package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.FunctionalPackages: + raise HTTPException( + status_code=404, detail="Package not found" + ) + catalog.Catalog.FunctionalPackages[package_id] = pkg + return pkg + + +@router.delete("/packages/miscellaneous/{package_id:path}") +async def delete_miscellaneous_package( + package_id: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete a miscellaneous package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.FunctionalPackages: + raise HTTPException( + status_code=404, detail="Package not found" + ) + del catalog.Catalog.FunctionalPackages[package_id] + catalog.Catalog.Miscellaneous = [id for id in catalog.Catalog.Miscellaneous if id != package_id] + return {"message": "Package deleted"} + + +# ─── Driver Packages ──────────────────────────────────────── + +@router.post("/packages/driver") +async def add_driver_package( + pkg: DriverPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Add a driver package (in-memory, no disk save).""" + catalog = service.get_catalog() + package_id = service.generate_package_id( + pkg.Name, "driver" + ) + catalog.Catalog.DriverPackages[package_id] = pkg + # Don't save to disk - only save on explicit user action + return {"package_id": package_id, "package": pkg} + + +@router.put("/packages/driver/{package_id:path}") +async def update_driver_package( + package_id: str, + pkg: DriverPackage, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> DriverPackage: + """Update a driver package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.DriverPackages: + raise HTTPException( + status_code=404, detail="Driver package not found" + ) + catalog.Catalog.DriverPackages[package_id] = pkg + return pkg + + +@router.delete("/packages/driver/{package_id:path}") +async def delete_driver_package( + package_id: str, + service: CatalogEditorService = Depends(get_catalog_editor_service), +) -> dict: + """Delete a driver package (in-memory, no disk save).""" + package_id = unquote(package_id) + + catalog = service.get_catalog() + if package_id not in catalog.Catalog.DriverPackages: + raise HTTPException( + status_code=404, detail="Driver package not found" + ) + del catalog.Catalog.DriverPackages[package_id] + return {"message": "Driver package deleted"} diff --git a/utils/gui/backend/api/v1/routes/local_repo_routes.py b/utils/gui/backend/api/v1/routes/local_repo_routes.py new file mode 100644 index 0000000000..587ce71660 --- /dev/null +++ b/utils/gui/backend/api/v1/routes/local_repo_routes.py @@ -0,0 +1,94 @@ +""" +Local repository routes for Config Editor Module + +Provides API endpoints for local repository configuration generation. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request +from typing import Dict, Any, Optional +from pathlib import Path + +from ....services.local_repo_generator_service import LocalRepoGeneratorService +from ....services.job_store import JobStore, TooManyConcurrentJobsError +from ....api.v1.dependencies import get_local_repo_generator_service + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def run_local_repo_generation( + job_id: str, + data: Dict[str, Any], + output_dir: Optional[Path], + local_repo_generator_service: LocalRepoGeneratorService, + job_store: JobStore, +) -> None: + """Run local repo generation in background. + + Args: + job_id: Job identifier + data: Local repo management data + output_dir: Optional output directory Path + local_repo_generator_service: Local repo generator service instance + job_store: Job store instance for tracking state + """ + try: + job_store.update_job(job_id, status="in_progress", progress=10) + + result = local_repo_generator_service.generate_local_repo_configs( + job_id=job_id, + update_job=job_store.update_job, + data=data, + output_dir=output_dir, + ) + + logger.info("Local repo config generation completed for job %s", job_id) + job_store.update_job(job_id, progress=100, status="completed", result=result) + + except Exception as e: + logger.exception("Local repo config generation failed for job %s", job_id) + job_store.update_job(job_id, status="failed", error=str(e), result={"error": str(e)}) + + +@router.post("/generate") +async def generate_local_repo( + data: Dict[str, Any], + background_tasks: BackgroundTasks, + request: Request, + local_repo_generator_service: LocalRepoGeneratorService = Depends(get_local_repo_generator_service), +) -> Dict[str, str]: + """Trigger local repository configuration generation. + + Args: + data: Local repo management data with rhel and ubuntu sections + background_tasks: FastAPI background tasks + request: FastAPI request object + local_repo_generator_service: Local repo generator service instance + + Returns: + Dictionary with job_id + """ + job_store = request.app.state.job_store + + try: + job_id = job_store.create_job() + except TooManyConcurrentJobsError: + raise HTTPException(429, "Too many generation jobs in progress. Please wait.") + + output_dir = data.get("output_dir", None) + output_path = Path(output_dir).expanduser().resolve() if output_dir else None + + generation_data = {k: v for k, v in data.items() if k != "output_dir"} + + background_tasks.add_task( + run_local_repo_generation, + job_id, + generation_data, + output_path, + local_repo_generator_service, + job_store, + ) + + logger.info("Started local repo config generation job %s", job_id) + return {"job_id": job_id, "status": "pending"} diff --git a/utils/gui/backend/api/v1/routes/wizard_routes.py b/utils/gui/backend/api/v1/routes/wizard_routes.py new file mode 100644 index 0000000000..0a75ced3d3 --- /dev/null +++ b/utils/gui/backend/api/v1/routes/wizard_routes.py @@ -0,0 +1,186 @@ +""" +Wizard routes for Config Editor Module + +Provides API endpoints for configuration generation and validation. +""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request +from typing import Dict, Any, Optional +from pathlib import Path + +from ....services.wizard_generator_service import WizardGeneratorService, GENERATED_CONFIG_FILENAMES +from ....services.job_store import JobStore, TooManyConcurrentJobsError +from ....api.v1.schemas.wizard_schemas import ( + DownloadFilesRequest +) +from ..dependencies import get_wizard_generator_service + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def run_catalog_generation(job_id: str, wizard_data: Dict[str, Any], + output_dir: Optional[Path], + wizard_generator_service: WizardGeneratorService, + job_store: JobStore) -> None: + """Run catalog generation in background. + + Args: + job_id: Job identifier + wizard_data: Wizard configuration data + output_dir: Optional output directory Path + wizard_generator_service: Wizard generator service instance + job_store: Job store instance for tracking state + """ + try: + job_store.update_job(job_id, status="in_progress", progress=10) + + # Use existing backend functions to generate all configs + job_store.update_job(job_id, progress=30) + logger.info("Starting config generation for job %s", job_id) + + result = wizard_generator_service.generate_all_configs( + job_id=job_id, + update_job=job_store.update_job, + wizard_data=wizard_data, + output_dir=output_dir + ) + + logger.info("Config generation completed for job %s", job_id) + job_store.update_job(job_id, progress=100, status="completed", result=result) + + except Exception as e: + logger.exception("Config generation failed for job %s", job_id) + job_store.update_job(job_id, status="failed", error=str(e), result={"error": str(e)}) + + +# Endpoints +@router.post("/generate-all") +async def generate_all( + wizard_data: Dict[str, Any], + background_tasks: BackgroundTasks, + request: Request, + wizard_generator_service: WizardGeneratorService = Depends(get_wizard_generator_service) +) -> Dict[str, str]: + """Trigger configuration generation. + + Args: + wizard_data: Wizard configuration data + background_tasks: FastAPI background tasks + request: FastAPI request object + wizard_generator_service: Wizard generator service instance + + Returns: + Dictionary with job_id + """ + job_store = request.app.state.job_store + + try: + job_id = job_store.create_job() + except TooManyConcurrentJobsError: + raise HTTPException(429, "Too many generation jobs in progress. Please wait.") + + # Extract output_dir without mutating the request body + output_dir = wizard_data.get("output_dir", None) + output_path = Path(output_dir).expanduser().resolve() if output_dir else None + + generation_data = {k: v for k, v in wizard_data.items() if k != "output_dir"} + + background_tasks.add_task( + run_catalog_generation, + job_id, + generation_data, + output_path, + wizard_generator_service, + job_store + ) + + logger.info("Started config generation job %s", job_id) + return {"job_id": job_id, "status": "pending"} + + +@router.get("/generate-all/{job_id}") +async def get_job_status(job_id: str, request: Request) -> Dict[str, Any]: + """Get job status. + + Args: + job_id: Job identifier + request: FastAPI request object + + Returns: + Job status dictionary + """ + job_store = request.app.state.job_store + job = job_store.get_job(job_id) + if not job: + raise HTTPException(404, "Job not found") + logger.debug("Job status for %s: %s", job_id, job.get("status")) + return job + + +@router.post("/download-files") +async def download_files( + request: DownloadFilesRequest +): + """Download generated configuration files as a ZIP archive. + + Args: + request: DownloadFilesRequest containing input_dir path + + Returns: + ZIP file containing all generated configuration files + """ + from fastapi.responses import FileResponse + from starlette.background import BackgroundTask + import zipfile + import tempfile + import shutil + from pathlib import Path + import uuid + + input_dir = request.input_dir + if not input_dir: + raise HTTPException(400, "input_dir is required") + + input_path = Path(input_dir) + if not input_path.exists(): + raise HTTPException(404, f"Input directory not found: {input_dir}") + + # Only include files that were generated by the wizard + files_to_zip = [ + input_path / filename + for filename in GENERATED_CONFIG_FILENAMES + if (input_path / filename).is_file() + ] + if not files_to_zip: + raise HTTPException(status_code=400, detail="No configuration files generated. Please generate files before downloading.") + + # Create a temporary directory for the ZIP file + temp_dir = Path(tempfile.gettempdir()) / f"omnia-download-{uuid.uuid4()}" + temp_dir.mkdir(exist_ok=True) + zip_path = temp_dir / "omnia-config-files.zip" + + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for file_path in files_to_zip: + zipf.write(file_path, file_path.name) + + # Return the file with a background task to clean up + def cleanup(): + try: + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception as e: + logger.warning("Failed to cleanup temp directory %s: %s", temp_dir, e) + + return FileResponse( + zip_path, + media_type="application/zip", + filename="omnia-config-files.zip", + background=BackgroundTask(cleanup) + ) + except Exception as e: + logger.exception("Failed to create ZIP file") + # Clean up on error + shutil.rmtree(temp_dir, ignore_errors=True) + raise HTTPException(500, f"Failed to create ZIP file: {str(e)}") diff --git a/utils/gui/backend/api/v1/schemas/__init__.py b/utils/gui/backend/api/v1/schemas/__init__.py new file mode 100644 index 0000000000..8393917002 --- /dev/null +++ b/utils/gui/backend/api/v1/schemas/__init__.py @@ -0,0 +1,29 @@ +"""Schemas module for catalog editor API.""" + +from .catalog_editor_schemas import ( + PackageDefinition, + BundlePackages, + OSPackageTemplate, + TemplateListResponse, + OSCombination, + BundleInfo, + BundleListResponse, + PackageSearchResult, + PackageSearchResponse +) +from .wizard_schemas import ( + DownloadFilesRequest +) + +__all__ = [ + "PackageDefinition", + "BundlePackages", + "OSPackageTemplate", + "TemplateListResponse", + "OSCombination", + "BundleInfo", + "BundleListResponse", + "PackageSearchResult", + "PackageSearchResponse", + "DownloadFilesRequest" +] diff --git a/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py b/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py new file mode 100644 index 0000000000..58863d4d53 --- /dev/null +++ b/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py @@ -0,0 +1,68 @@ +"""Schemas for catalog editor API.""" + +from pydantic import BaseModel +from typing import List, Dict, Optional + + +class PackageDefinition(BaseModel): + """Definition of a single package.""" + package: str + type: str # rpm, image, git, tarball, etc. + repo_name: Optional[str] = None + url: Optional[str] = None + tag: Optional[str] = None + version: Optional[str] = None + + +class BundlePackages(BaseModel): + """Packages within a bundle.""" + bundle_name: str + packages: List[PackageDefinition] + + +class OSPackageTemplate(BaseModel): + """Complete OS package template.""" + template_key: str + bundles: Dict[str, List[PackageDefinition]] + + +class TemplateListResponse(BaseModel): + """Response for listing available templates.""" + templates: List[str] + + +class OSCombination(BaseModel): + """OS/arch/version combination.""" + os_family: str + version: str + arch: str + + +class BundleInfo(BaseModel): + """Information about a bundle.""" + name: str + type: str # functional, infrastructure, os + package_count: int + sections: List[str] + + +class BundleListResponse(BaseModel): + """Response for listing bundles.""" + bundles: List[BundleInfo] + + +class PackageSearchResult(BaseModel): + """Result of package search.""" + package: str + type: str + bundle: str + section: str + repo_name: Optional[str] = None + url: Optional[str] = None + tag: Optional[str] = None + version: Optional[str] = None + + +class PackageSearchResponse(BaseModel): + """Response for package search.""" + results: List[PackageSearchResult] diff --git a/utils/gui/backend/api/v1/schemas/wizard_schemas.py b/utils/gui/backend/api/v1/schemas/wizard_schemas.py new file mode 100644 index 0000000000..755f5e1113 --- /dev/null +++ b/utils/gui/backend/api/v1/schemas/wizard_schemas.py @@ -0,0 +1,8 @@ +"""Schemas for wizard configuration API.""" + +from pydantic import BaseModel + + +class DownloadFilesRequest(BaseModel): + """Request model for download-files endpoint.""" + input_dir: str diff --git a/utils/gui/backend/app.py b/utils/gui/backend/app.py new file mode 100644 index 0000000000..bd78bf9688 --- /dev/null +++ b/utils/gui/backend/app.py @@ -0,0 +1,143 @@ +""" +FastAPI application for Config Editor Module (Refactored) + +This is a production-ready FastAPI service with proper structure, +middleware, dependency injection, and configuration management. +""" + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +import logging + +from .config.settings import get_settings +from .config.logging import setup_logging +from .core.middleware import configure_middleware +from .core.exceptions import ConfigEditorException +from .api.v1.routes import catalog_routes, wizard_routes, adapter_policy_routes, catalog_editor_router, local_repo_routes +from .services.job_store import JobStore + +# Setup logging +settings = get_settings() +setup_logging(level=settings.log_level) +logger = logging.getLogger(__name__) + +# Create FastAPI app +app = FastAPI( + title=settings.api_title, + description=settings.api_description, + version=settings.api_version, + docs_url="/docs", + redoc_url="/redoc" +) + +# Configure middleware +configure_middleware(app) + +# Initialize app state +app.state.job_store = JobStore(max_concurrent_jobs=3) +logger.info("Initialized JobStore in app.state") +app.state.catalog = None +logger.info("Initialized catalog in app.state") + +# Include routers +api_prefix = settings.api_prefix +app.include_router(adapter_policy_routes.router, prefix=api_prefix, tags=["adapter-policy"]) +app.include_router(catalog_routes.router, prefix=api_prefix, tags=["catalog"]) +app.include_router(wizard_routes.router, prefix=f"{api_prefix}/config", tags=["wizard"]) +app.include_router(local_repo_routes.router, prefix=f"{api_prefix}/local-repo", tags=["local-repo"]) +app.include_router(catalog_editor_router, prefix=api_prefix, tags=["catalog-editor"]) + + +# Exception handlers +@app.exception_handler(ConfigEditorException) +async def config_editor_exception_handler(request, exc: ConfigEditorException): + """Handle custom ConfigEditorException.""" + logger.error("ConfigEditorException: %s", exc.message) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": exc.message, + "details": exc.details + } + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc: RequestValidationError): + """Handle request validation errors.""" + logger.error("Validation error: %s", exc.errors()) + return JSONResponse( + status_code=422, + content={ + "error": "Validation error", + "details": exc.errors() + } + ) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request, exc: StarletteHTTPException): + """Handle HTTP exceptions.""" + logger.error("HTTP exception: %s - %s", exc.status_code, exc.detail) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": exc.detail + } + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request, exc: Exception): + """Handle general exceptions.""" + logger.exception("Unhandled exception") + return JSONResponse( + status_code=500, + content={ + "error": "Internal server error", + "details": None # Never expose exception details to avoid information leakage + } + ) + + +# Root endpoints +@app.get("/") +async def root(): + """Root endpoint.""" + return { + "message": settings.api_title, + "version": settings.api_version, + "status": "running", + "environment": settings.environment + } + + +@app.get("/test-simple") +async def test_simple(): + """Simple test endpoint (only available in debug mode).""" + if not settings.debug: + raise StarletteHTTPException(status_code=404, detail="Not found") + return {"message": "Simple test working", "data": "test"} + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "healthy", + "environment": settings.environment + } + + +if __name__ == "__main__": + import uvicorn + logger.info("Starting %s...", settings.api_title) + uvicorn.run( + "utils.gui.backend.app:app", + host=settings.host, + port=settings.port, + reload=settings.reload, + log_level=settings.log_level + ) diff --git a/utils/gui/backend/config/__init__.py b/utils/gui/backend/config/__init__.py new file mode 100644 index 0000000000..1c6f086b67 --- /dev/null +++ b/utils/gui/backend/config/__init__.py @@ -0,0 +1,6 @@ +"""Configuration module.""" + +from .settings import Settings, get_settings, reset_settings +from .logging import setup_logging + +__all__ = ["Settings", "get_settings", "reset_settings", "setup_logging"] diff --git a/utils/gui/backend/config/defaults.py b/utils/gui/backend/config/defaults.py new file mode 100644 index 0000000000..d838c5e8b9 --- /dev/null +++ b/utils/gui/backend/config/defaults.py @@ -0,0 +1,90 @@ +"""Default configuration dictionaries for GUI input file generators. + +These defaults are used when the corresponding wizard step is skipped or disabled, +so the backend always receives a schema-valid file. Values are seeded from the +example files in `omnia/input/*.yml` with all feature flags set to disabled. +""" + +from typing import Any, Dict + + +def get_build_stream_config_defaults() -> Dict[str, Any]: + return {'enable_build_stream': False, 'build_stream_host_ip': '', 'build_stream_port': 8010, 'aarch64_inventory_host_ip': ''} + + +def get_discovery_config_defaults() -> Dict[str, Any]: + return {'enable_bmc_discovery': False, 'ome_ip': ''} + + +def get_telemetry_config_defaults() -> Dict[str, Any]: + return {'telemetry_sources': {'idrac': {'metrics_enabled': False, 'collection_targets': ['victoria_metrics', 'kafka']}, + 'ldms': {'metrics_enabled': False, 'collection_targets': ['kafka']}, + 'dcgm': {'metrics_enabled': False}, + 'powerscale': {'metrics_enabled': False, 'logs_enabled': False, 'collection_targets': ['victoria_metrics', 'victoria_logs']}, + 'ufm': {'metrics_enabled': False, 'logs_enabled': False, 'collection_targets': ['victoria_metrics', 'victoria_logs']}, + 'vast': {'metrics_enabled': False, 'logs_enabled': False, 'collection_targets': ['victoria_metrics', 'victoria_logs']}, + 'ome': {'metrics_enabled': False, 'logs_enabled': False, 'collection_targets': ['kafka']}}, + 'telemetry_bridges': {'vector_ldms': {'metrics_enabled': False}, 'vector_ome': {'metrics_enabled': False, 'logs_enabled': False, 'ome_identifier': 'ome'}}, + 'telemetry_sinks': {'victoria_metrics': {'persistence_size': '8Gi', 'retention_period': 168, 'additional_metric_remote_write_endpoints': []}, + 'victoria_logs': {'storage_size': '8Gi', 'retention_period': 168, 'additional_log_write_endpoints': []}, + 'kafka': {'persistence_size': '8Gi', + 'log_retention_hours': 168, + 'log_retention_bytes': -1, + 'log_segment_bytes': 1073741824, + 'topic_partitions': {'idrac': 1, 'ldms': 2}}}, + 'idrac_telemetry_configurations': {'mysqldb_storage': '1Gi'}, + 'ldms_configurations': {'agg_port': 6001, 'store_port': 6001, 'sampler_port': 10001, 'sampler_plugins': []}, + 'powerscale_configurations': {'otel_collector_storage_size': '5Gi', 'csm_observability_values_file_path': ''}, + 'ufm_configuration': {'ufm_endpoint': '', + 'ufm_metrics_port': 9001, + 'scrape_interval': '30s', + 'scrape_timeout': '15s', + 'tls_mode': 'self_signed', + 'ufm_ca_cert_path': '', + 'auth_mode': 'basic'}, + 'vast_configuration': {'vast_endpoint': '', + 'vast_metrics_port': 443, + 'metrics_path': '/api/prometheusmetrics/all', + 'scrape_interval': '30s', + 'scrape_timeout': '15s', + 'tls_mode': 'self_signed', + 'vast_ca_cert_path': '', + 'auth_mode': 'basic'}} + + +def get_telemetry_storage_config_defaults() -> Dict[str, Any]: + return {'victoria_cluster_storage': {'vmstorage': {'replicas': 3, + 'resources': {'requests': {'memory': '1Gi', 'cpu': '250m'}, 'limits': {'memory': '2Gi', 'cpu': '1000m'}}}, + 'vminsert': {'replicas': 2, + 'resources': {'requests': {'memory': '256Mi', 'cpu': '100m'}, 'limits': {'memory': '512Mi', 'cpu': '500m'}}}, + 'vmselect': {'replicas': 2, + 'resources': {'requests': {'memory': '256Mi', 'cpu': '100m'}, 'limits': {'memory': '512Mi', 'cpu': '500m'}}}, + 'vmagent': {'replicas': 2, + 'resources': {'requests': {'memory': '128Mi', 'cpu': '50m'}, 'limits': {'memory': '512Mi', 'cpu': '250m'}}}}, + 'victoria_logs_cluster_storage': {'vlstorage': {'replicas': 3, + 'resources': {'requests': {'memory': '512Mi', 'cpu': '100m'}, 'limits': {'memory': '1Gi', 'cpu': '500m'}}}, + 'vlinsert': {'replicas': 2, + 'resources': {'requests': {'memory': '256Mi', 'cpu': '100m'}, 'limits': {'memory': '512Mi', 'cpu': '500m'}}}, + 'vlselect': {'replicas': 2, + 'resources': {'requests': {'memory': '256Mi', 'cpu': '100m'}, 'limits': {'memory': '512Mi', 'cpu': '500m'}}}, + 'vlagent': {'replicas': 2, + 'pvc_size': '5Gi', + 'resources': {'requests': {'memory': '64Mi', 'cpu': '25m'}, 'limits': {'memory': '256Mi', 'cpu': '100m'}}}}, + 'vector_storage': {'ldms': {'replicas': 2, 'resources': {'requests': {'memory': '128Mi', 'cpu': '50m'}, 'limits': {'memory': '256Mi', 'cpu': '250m'}}}, + 'ome': {'replicas': 2, 'resources': {'requests': {'memory': '256Mi', 'cpu': '100m'}, 'limits': {'memory': '512Mi', 'cpu': '500m'}}}, + 'vlagent_vector': {'replicas': 2, + 'pvc_size': '5Gi', + 'resources': {'requests': {'memory': '128Mi', 'cpu': '50m'}, 'limits': {'memory': '256Mi', 'cpu': '250m'}}}, + 'vmagent_vector': {'replicas': 2, + 'pvc_size': '5Gi', + 'resources': {'requests': {'memory': '128Mi', 'cpu': '50m'}, 'limits': {'memory': '256Mi', 'cpu': '250m'}}}}, + 'csi_volume_exporter_storage': {'resources': {'requests': {'cpu': '50m', 'memory': '64Mi'}, 'limits': {'cpu': '200m', 'memory': '256Mi'}}}, + 'csm_metrics_powerscale_storage': {'requests': {'cpu': '100m', 'memory': '128Mi'}, 'limits': {'cpu': '500m', 'memory': '512Mi'}}, + 'idrac_telemetry_storage': {'mysqldb': {'resources': {'requests': {'cpu': '100m', 'memory': '256Mi'}, 'limits': {'cpu': '500m', 'memory': '512Mi'}}}, + 'activemq': {'resources': {'requests': {'cpu': '100m', 'memory': '512Mi'}, 'limits': {'cpu': '500m', 'memory': '1536Mi'}}}, + 'receiver': {'resources': {'requests': {'cpu': '100m', 'memory': '128Mi'}, 'limits': {'cpu': '500m', 'memory': '256Mi'}}}, + 'kafka_pump': {'resources': {'requests': {'cpu': '50m', 'memory': '128Mi'}, 'limits': {'cpu': '200m', 'memory': '512Mi'}}}, + 'victoria_pump': {'resources': {'requests': {'cpu': '50m', 'memory': '128Mi'}, 'limits': {'cpu': '200m', 'memory': '512Mi'}}}}, + 'kafka_storage': {'kafka': {'resources': {'requests': {'memory': '512Mi', 'cpu': '200m'}, 'limits': {'memory': '1Gi', 'cpu': '1000m'}}}, + 'entity_operator': {'user_operator': {'resources': {'requests': {'memory': '512Mi', 'cpu': '200m'}, + 'limits': {'memory': '512Mi', 'cpu': '1000m'}}}}}} diff --git a/utils/gui/backend/config/logging.py b/utils/gui/backend/config/logging.py new file mode 100644 index 0000000000..c65e86cabd --- /dev/null +++ b/utils/gui/backend/config/logging.py @@ -0,0 +1,57 @@ +""" +Logging configuration for Config Editor Module + +Provides centralized logging setup with proper formatting and handlers. +""" + +import logging +import sys +from typing import Optional +from pathlib import Path + + +def setup_logging( + level: str = "INFO", + log_file: Optional[Path] = None, + log_format: Optional[str] = None +) -> None: + """ + Setup logging configuration for the application. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_file: Optional path to log file + log_format: Optional custom log format string + """ + if log_format is None: + log_format = ( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + # Convert string level to logging constant + log_level = getattr(logging, level.upper(), None) + if log_level is None: + raise ValueError(f"Invalid log level: {level!r}") + + # Clear existing handlers to prevent duplicates on repeated calls + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(log_level) + + # Console handler + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(log_level) + console_handler.setFormatter(logging.Formatter(log_format)) + root_logger.addHandler(console_handler) + + # File handler if specified + if log_file: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler.setLevel(log_level) + file_handler.setFormatter(logging.Formatter(log_format)) + root_logger.addHandler(file_handler) + + # Set specific loggers to appropriate levels + logging.getLogger("uvicorn").setLevel(logging.INFO) + logging.getLogger("uvicorn.access").setLevel(logging.INFO) diff --git a/utils/gui/backend/config/settings.py b/utils/gui/backend/config/settings.py new file mode 100644 index 0000000000..713363b836 --- /dev/null +++ b/utils/gui/backend/config/settings.py @@ -0,0 +1,65 @@ +""" +Configuration settings for Config Editor Module + +Provides environment-based configuration management using standard environment variables. +""" + +import os +from typing import List +from pathlib import Path + + +class Settings: + """Application settings loaded from environment variables.""" + + def __init__(self): + # API Configuration + self.api_title = os.getenv("API_TITLE", "OMNIA Config Editor API") + self.api_description = os.getenv("API_DESCRIPTION", "Backend API for OMNIA Configuration Editor GUI") + self.api_version = os.getenv("API_VERSION", "1.0.0") + self.api_prefix = os.getenv("API_PREFIX", "/api/v1") + + # Server Configuration + self.host = os.getenv("HOST", "0.0.0.0") + self.port = int(os.getenv("PORT", "8000")) + self.reload = os.getenv("RELOAD", "true").lower() == "true" + self.log_level = os.getenv("LOG_LEVEL", "info") + + # CORS Configuration + cors_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001") + self.cors_origins = [origin.strip() for origin in cors_origins_str.split(",")] + self.cors_allow_credentials = os.getenv("CORS_ALLOW_CREDENTIALS", "true").lower() == "true" + self.cors_allow_methods = os.getenv("CORS_ALLOW_METHODS", "*").split(",") + self.cors_allow_headers = os.getenv("CORS_ALLOW_HEADERS", "*").split(",") + + # Path Configuration + self.base_dir = Path(__file__).parent.parent.parent.parent.parent # Repository root + self.build_stream_dir = self.base_dir / "build_stream" + self.gui_dir = self.base_dir / "utils" / "gui" + self.base_input_dir = self.base_dir / "input" # Base input for bundle files (repo root/input) + self.examples_dir = self.base_dir / "examples" + + # Output Configuration + self.output_dir = self.gui_dir / "out" # utils/gui/out + + # Environment + self.environment = os.getenv("ENVIRONMENT", "development") + self.debug = os.getenv("DEBUG", "true").lower() == "true" + + +# Global settings instance +_settings: Settings | None = None + + +def get_settings() -> Settings: + """Get or create the global settings instance.""" + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def reset_settings() -> None: + """Reset the settings instance (for testing).""" + global _settings + _settings = None diff --git a/utils/gui/backend/core/__init__.py b/utils/gui/backend/core/__init__.py new file mode 100644 index 0000000000..7cffe652cf --- /dev/null +++ b/utils/gui/backend/core/__init__.py @@ -0,0 +1,19 @@ +"""Core module.""" + +from .exceptions import * +from .middleware import configure_middleware, CORSMiddlewareConfig + +__all__ = [ + "ConfigEditorException", + "ConfigurationNotFoundError", + "CatalogNotFoundError", + "AdapterPolicyNotFoundError", + "InvalidConfigurationError", + "GenerationError", + "PXEMappingNotFoundError", + "SoftwareConfigNotFoundError", + "BundleNotFoundError", + "FunctionalLayerNotFoundError", + "configure_middleware", + "CORSMiddlewareConfig" +] diff --git a/utils/gui/backend/core/constants.py b/utils/gui/backend/core/constants.py new file mode 100644 index 0000000000..803fb9c328 --- /dev/null +++ b/utils/gui/backend/core/constants.py @@ -0,0 +1,11 @@ +"""Shared constants for Config Editor module.""" + +# Bundle classification constants +# Single source of truth for bundle categorization across services +FUNCTIONAL_BUNDLES = frozenset({"service_k8s", "slurm_custom", "additional_packages"}) +INFRA_BUNDLES = frozenset({"csi_driver_powerscale"}) +OS_BUNDLES = frozenset({ + "default_packages", "admin_debug_packages", + "openldap", "openmpi", "ucx", "ldms", "nfs", +}) +ALL_KNOWN_BUNDLES = FUNCTIONAL_BUNDLES | INFRA_BUNDLES | OS_BUNDLES diff --git a/utils/gui/backend/core/exceptions.py b/utils/gui/backend/core/exceptions.py new file mode 100644 index 0000000000..4585c2e0d0 --- /dev/null +++ b/utils/gui/backend/core/exceptions.py @@ -0,0 +1,130 @@ +""" +Custom exception classes for Config Editor Module + +Provides domain-specific exceptions for better error handling and user feedback. +""" + +from typing import Optional, Any + + +class ConfigEditorException(Exception): + """Base exception for Config Editor errors.""" + + def __init__( + self, + message: str, + status_code: int = 500, + details: Optional[dict[str, Any]] = None + ): + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +class ConfigurationNotFoundError(ConfigEditorException): + """Raised when a configuration file is not found.""" + + def __init__(self, config_name: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Configuration not found: {config_name}", + status_code=404, + details=details + ) + self.config_name = config_name + + +class CatalogNotFoundError(ConfigEditorException): + """Raised when a catalog file is not found.""" + + def __init__(self, catalog_path: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Catalog file not found: {catalog_path}", + status_code=404, + details=details + ) + self.catalog_path = catalog_path + + +class AdapterPolicyNotFoundError(ConfigEditorException): + """Raised when an adapter policy is not found.""" + + def __init__(self, policy_type: str = "custom", details: Optional[dict[str, Any]] = None): + super().__init__( + f"Adapter policy not found: {policy_type}", + status_code=404, + details=details + ) + self.policy_type = policy_type + + +class InvalidConfigurationError(ConfigEditorException): + """Raised when configuration data is invalid.""" + + def __init__(self, message: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Invalid configuration: {message}", + status_code=400, + details=details + ) + + +class GenerationError(ConfigEditorException): + """Raised when configuration generation fails.""" + + def __init__(self, message: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Configuration generation failed: {message}", + status_code=500, + details=details + ) + + +class PXEMappingNotFoundError(ConfigEditorException): + """Raised when PXE mapping file is not found.""" + + def __init__(self, mapping_path: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"PXE mapping file not found: {mapping_path}", + status_code=404, + details=details + ) + self.mapping_path = mapping_path + + +class SoftwareConfigNotFoundError(ConfigEditorException): + """Raised when software config file is not found.""" + + def __init__(self, config_path: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Software config file not found: {config_path}", + status_code=404, + details=details + ) + self.config_path = config_path + + +class BundleNotFoundError(ConfigEditorException): + """Raised when a bundle is not found.""" + + def __init__(self, bundle_name: str, arch: Optional[str] = None, details: Optional[dict[str, Any]] = None): + arch_str = f" (arch: {arch})" if arch else "" + super().__init__( + f"Bundle not found: {bundle_name}{arch_str}", + status_code=404, + details=details + ) + self.bundle_name = bundle_name + self.arch = arch + + +class FunctionalLayerNotFoundError(ConfigEditorException): + """Raised when a functional layer is not found.""" + + def __init__(self, layer_name: str, details: Optional[dict[str, Any]] = None): + super().__init__( + f"Functional layer not found: {layer_name}", + status_code=404, + details=details + ) + self.layer_name = layer_name diff --git a/utils/gui/backend/core/middleware.py b/utils/gui/backend/core/middleware.py new file mode 100644 index 0000000000..d5c98af41d --- /dev/null +++ b/utils/gui/backend/core/middleware.py @@ -0,0 +1,83 @@ +""" +Custom middleware for Config Editor Module + +Provides CORS and logging middleware. +""" + +import logging +from fastapi import Request, Response +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +from ..config.settings import get_settings + +logger = logging.getLogger(__name__) + + +class CORSMiddlewareConfig: + """CORS middleware configuration based on environment.""" + + @staticmethod + def get_cors_origins(): + """Get allowed CORS origins from settings.""" + settings = get_settings() + return settings.cors_origins + + @staticmethod + def configure_cors(app): + """Configure CORS middleware for the application. + + Args: + app: FastAPI application instance + """ + settings = get_settings() + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=settings.cors_allow_credentials, + allow_methods=settings.cors_allow_methods, + allow_headers=settings.cors_allow_headers, + ) + + +class LoggingMiddleware(BaseHTTPMiddleware): + """Middleware for logging HTTP requests and responses.""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """Process request and log details. + + Args: + request: Incoming request + call_next: Next middleware or route handler + + Returns: + Response from the next middleware/route + """ + # Skip noisy paths + if request.url.path in ("/health", "/metrics"): + return await call_next(request) + + # Log request + logger.debug("Request: %s %s", request.method, request.url.path) + + # Process request + response = await call_next(request) + + # Log response + logger.debug("Response: %s for %s %s", response.status_code, request.method, request.url.path) + + return response + + +def configure_middleware(app): + """Configure all middleware for the application. + + Args: + app: FastAPI application instance + """ + # Configure CORS first (outermost middleware runs first) + CORSMiddlewareConfig.configure_cors(app) + + # Add logging middleware + app.add_middleware(LoggingMiddleware) diff --git a/utils/gui/backend/models/__init__.py b/utils/gui/backend/models/__init__.py new file mode 100644 index 0000000000..3b54f99858 --- /dev/null +++ b/utils/gui/backend/models/__init__.py @@ -0,0 +1,30 @@ +"""Models module.""" + +from .schemas import * +from .catalog_schemas import * + +__all__ = [ + "SchedulerType", + "Architecture", + "PackageType", + "Package", + "Bundle", + "SoftwareConfig", + "PXEFunctionalGroup", + "FunctionalLayer", + "BaseOS", + "Infrastructure", + "CatalogInner", + "CatalogRoot", + "DriverConfig", + "Driver", + "DriverPackage", + "MiscellaneousPackage", + "FunctionalPackage", + "InfrastructurePackage", + "SupportedOSInfo", + "PackageSource", + "DeploymentConfig", + "FeatureConfig", + "Configuration" +] diff --git a/utils/gui/backend/models/catalog_schemas.py b/utils/gui/backend/models/catalog_schemas.py new file mode 100644 index 0000000000..3cbf8e8f0a --- /dev/null +++ b/utils/gui/backend/models/catalog_schemas.py @@ -0,0 +1,212 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, List, Optional +from enum import Enum + + +class PackageType(str, Enum): + """All package types found in the real catalog.""" + RPM = "rpm" + RPM_REPO = "rpm_repo" + TARBALL = "tarball" + ISO = "iso" + GIT = "git" + IMAGE = "image" + PIP_MODULE = "pip_module" + MANIFEST = "manifest" + + +class SupportedOSInfo(BaseModel): + Name: str + Version: str + + +class PackageSource(BaseModel): + Architecture: str + RepoName: Optional[str] = None + Uri: Optional[str] = None + + +class FunctionalPackage(BaseModel): + """ + Base package schema for Schema 1.0. + + Schema 1.0 fields: + - Name, Type, Architecture: Required + - SupportedOS: Optional (omitted by some package types like pip_module) + - Sources: Optional (omitted by pip_module packages) + - Version: Optional (not always available from bundle files) + - Tag: Optional (only for image packages) + + Schema 1.1 fields (to be added later): + - ApplicableFunctionalLayers: Maps packages to functional layers + - Config: Enhanced package metadata + - SupportedFunctions: Function metadata + """ + Name: str + Type: PackageType + Architecture: List[str] + SupportedOS: Optional[List[SupportedOSInfo]] = None + Sources: Optional[List[PackageSource]] = None + Version: Optional[str] = None + Tag: Optional[str] = None + +# OS packages share the same schema as functional packages +OSPackage = FunctionalPackage + +class InfrastructurePackage(BaseModel): + """ + Infrastructure package schema for Schema 1.0. + + Schema 1.0 fields (based on core/catalog/parser.py): + - Name, Type, SupportedFunctions: Required + - Architecture: Optional (defaults to []) + - Uri: Optional (defaults to "") + - Sources: Optional (defaults to []) + - Version: Optional + - Tag: Optional (defaults to "") + + Schema 1.1 fields (to be added later): + - ApplicableFunctionalLayers: Maps packages to functional layers + - Config: Enhanced package metadata + """ + Name: str + Type: PackageType + Architecture: List[str] = [] + SupportedFunctions: List[Dict[str, str]] = [] + Uri: str = "" + Sources: Optional[List[PackageSource]] = None + Version: Optional[str] = None + Tag: str = "" + + +class DriverConfig(BaseModel): + """Driver-specific configuration fields.""" + DriverBrand: Optional[str] = None + DriverType: Optional[str] = None + + +class DriverPackage(BaseModel): + """ + Driver package schema for Schema 1.0. + + Schema 1.0 fields (based on core/catalog/parser.py): + - Name, Type, Architecture, Uri, Version, Config: Required + - Tag: Not used by parser + - Sources: Not used by parser + + Schema 1.1 fields (to be added later): + - ApplicableFunctionalLayers: Maps driver packages to functional layers + """ + Name: str + Type: PackageType + Architecture: List[str] + Uri: str + Config: DriverConfig = Field(default_factory=DriverConfig) + Version: str + + +class Driver(BaseModel): + """ + Driver layer entry with name and package references. + """ + Name: str + DriverPackages: List[str] + + +class MiscellaneousPackage(BaseModel): + """ + Miscellaneous package schema for Schema 1.0. + + Schema 1.0 fields: + - Name, Type, Architecture, Uri: Required + - Version: Optional + - Tag: Optional + - SupportedOS: Optional + - Sources: Optional + + Schema 1.1 fields (to be added later): + - ApplicableFunctionalLayers: Maps miscellaneous packages to functional layers + - Config: Enhanced package metadata + """ + Name: str + Type: PackageType + Architecture: List[str] + Uri: str + Version: Optional[str] = None + Tag: Optional[str] = None + SupportedOS: Optional[List[SupportedOSInfo]] = None + Sources: Optional[List[PackageSource]] = None + + +class FunctionalLayer(BaseModel): + """ + Functional layer schema for Schema 1.0. + + Schema 1.0 fields: + - Name: Layer name + - FunctionalPackages: Array of package ID references + + Schema 1.1 fields (to be added later): + - ApplicableFunctionalLayers: Maps layer to other layers (optional) + """ + Name: str + FunctionalPackages: List[str] + + +class BaseOS(BaseModel): + Name: str + Version: str + osPackages: List[str] + + +class Infrastructure(BaseModel): + Name: str + InfrastructurePackages: List[str] + + +class CatalogInner(BaseModel): + """ + The real catalog nests ALL data under a single "Catalog" key. + Metadata fields (Name, Version, Identifier) sit alongside + FunctionalLayer, FunctionalPackages, etc. + + Schema 1.0 fields: + - Name: "Catalog" (default) + - Version: "1.0" (default) + - Identifier: "image-build" (default) + - FunctionalLayer: Array of functional layer definitions + - BaseOS: Array of OS package definitions + - Infrastructure: Array of infrastructure definitions + - Drivers: Array of driver category definitions + - DriverPackages: Dictionary of driver package definitions + - FunctionalPackages: Dictionary of functional package definitions + - OSPackages: Dictionary of OS package definitions + - InfrastructurePackages: Dictionary of infrastructure package definitions + - Miscellaneous: Array of miscellaneous package references + + Schema 1.1 fields (to be added later): + - CatalogSchemaVersion: "1.1" when using Schema 1.1 features + - MiscellaneousPackages: Dictionary of miscellaneous package definitions with ApplicableFunctionalLayers + """ + # Metadata (Schema 1.0) + Name: str = "Catalog" + Version: str = "1.0" + Identifier: str = "image-build" + # CatalogSchemaVersion: Not set for Schema 1.0 compatibility (to be added in Schema 1.1) + + # Structural sections (Schema 1.0) + FunctionalLayer: List[FunctionalLayer] + BaseOS: List[BaseOS] + Infrastructure: List[Infrastructure] + Drivers: List[Driver] = [] + DriverPackages: Dict[str, DriverPackage] = {} + FunctionalPackages: Dict[str, FunctionalPackage] + OSPackages: Dict[str, OSPackage] + InfrastructurePackages: Dict[str, InfrastructurePackage] + Miscellaneous: List[str] = [] + # MiscellaneousPackages: Not set for Schema 1.0 compatibility (to be added in Schema 1.1) + + +class CatalogRoot(BaseModel): + """Top-level wrapper matching the real JSON: { "Catalog": { ... } }""" + Catalog: CatalogInner diff --git a/utils/gui/backend/models/schemas.py b/utils/gui/backend/models/schemas.py new file mode 100644 index 0000000000..db51e1faaa --- /dev/null +++ b/utils/gui/backend/models/schemas.py @@ -0,0 +1,100 @@ +""" +Data schemas and type definitions for Config Editor Module + +Provides data models and validation schemas for configuration files. +These correspond to the actual configuration file structures used by OMNIA. + +Note: Catalog-related types have been moved to catalog_schemas.py (Pydantic) +to avoid duplication and maintain a single source of truth. +""" + +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field +from enum import Enum + + +class SchedulerType(Enum): + """Scheduler type for cluster configuration""" + KUBERNETES = "kubernetes" + SLURM = "slurm" + BOTH = "both" + +@dataclass +class Package: + """Package definition""" + name: str + type: str # Using str instead of PackageType enum to avoid circular imports + version: Optional[str] = None + uri: Optional[str] = None + tag: Optional[str] = None + repo_name: Optional[str] = None + architecture: List[str] = field(default_factory=list) + + +@dataclass +class Bundle: + """Software bundle configuration""" + name: str + architecture: List[str] + packages: List[Package] = field(default_factory=list) + + +@dataclass +class SoftwareConfig: + """Software configuration from software_config.json""" + cluster_os_type: str = "rhel" + cluster_os_version: str = "10.0" + repo_config: str = "partial" + softwares: List[Dict[str, Any]] = field(default_factory=list) + slurm_custom: List[Dict[str, str]] = field(default_factory=list) + service_k8s: List[Dict[str, str]] = field(default_factory=list) + additional_packages: List[Dict[str, str]] = field(default_factory=list) + + +@dataclass +class PXEFunctionalGroup: + """PXE functional group mapping""" + functional_group_name: str + group_name: str + service_tag: Optional[str] = None + parent_service_tag: Optional[str] = None + hostname: Optional[str] = None + admin_mac: Optional[str] = None + admin_ip: Optional[str] = None + bmc_mac: Optional[str] = None + bmc_ip: Optional[str] = None + ib_nic_name: Optional[str] = None + ib_ip: Optional[str] = None + + +@dataclass +class DeploymentConfig: + """Deployment configuration (storage, security, omnia)""" + provision_config: Optional[Dict[str, Any]] = None + network_spec: Optional[Dict[str, Any]] = None + storage_config: Optional[Dict[str, Any]] = None + security_config: Optional[Dict[str, Any]] = None + omnia_config: Optional[Dict[str, Any]] = None + + +@dataclass +class FeatureConfig: + """Feature-specific configuration (optional)""" + telemetry_config: Optional[Dict[str, Any]] = None + telemetry_storage_config: Optional[Dict[str, Any]] = None + high_availability_config: Optional[Dict[str, Any]] = None + discovery_config: Optional[Dict[str, Any]] = None + gitlab_config: Optional[Dict[str, Any]] = None + local_repo_config: Optional[Dict[str, Any]] = None + build_stream_config: Optional[Dict[str, Any]] = None + user_registry_credential: Optional[Dict[str, Any]] = None + + +@dataclass +class Configuration: + """Complete configuration state""" + software_config: SoftwareConfig = field(default_factory=SoftwareConfig) + bundles: Dict[str, Bundle] = field(default_factory=dict) + pxe_functional_groups: List[PXEFunctionalGroup] = field(default_factory=list) + deployment_config: DeploymentConfig = field(default_factory=DeploymentConfig) + feature_config: FeatureConfig = field(default_factory=FeatureConfig) diff --git a/utils/gui/backend/requirements.txt b/utils/gui/backend/requirements.txt new file mode 100644 index 0000000000..a09fdf4a30 --- /dev/null +++ b/utils/gui/backend/requirements.txt @@ -0,0 +1,10 @@ +# Backend dependencies for the OMNIA GUI module + +# Web framework +fastapi>=0.109.0 +uvicorn>=0.24.0 +pydantic>=2.5.0 +starlette>=0.27.0 + +# Config/data handling +pyyaml>=6.0.1 diff --git a/utils/gui/backend/services/__init__.py b/utils/gui/backend/services/__init__.py new file mode 100644 index 0000000000..0d7555eee4 --- /dev/null +++ b/utils/gui/backend/services/__init__.py @@ -0,0 +1,17 @@ +"""Services module.""" + +from .config_service import ConfigService +from .adapter_policy_service import AdapterPolicyService +from .catalog_validation_service import CatalogValidationService +from .os_package_service import OSPackageService +from .software_config_service import SoftwareConfigService +from .wizard_generator_service import WizardGeneratorService + +__all__ = [ + "ConfigService", + "AdapterPolicyService", + "CatalogValidationService", + "OSPackageService", + "SoftwareConfigService", + "WizardGeneratorService" +] diff --git a/utils/gui/backend/services/adapter_policy_service.py b/utils/gui/backend/services/adapter_policy_service.py new file mode 100644 index 0000000000..2a83d1db84 --- /dev/null +++ b/utils/gui/backend/services/adapter_policy_service.py @@ -0,0 +1,82 @@ +""" +Adapter Policy Service for Config Editor Module + +Provides business logic for adapter policy management. +""" + +from pathlib import Path +from typing import Dict, Optional, Any +import logging + +from ..config.settings import get_settings +from ..utils.file_io import read_json, write_json +from ..core.exceptions import AdapterPolicyNotFoundError + +logger = logging.getLogger(__name__) + + +class AdapterPolicyService: + """Service for managing adapter policies.""" + + def __init__(self, settings=None): + """Initialize the service. + + Args: + settings: Optional settings instance. If None, uses default. + """ + self.settings = settings or get_settings() + self.gui_dir = self.settings.gui_dir + self.build_stream_dir = self.settings.build_stream_dir + + self.custom_policy_path = self.gui_dir / "backend" / "resources" / "adapter_policy_custom.json" + self.default_policy_path = self.build_stream_dir / "core" / "catalog" / "resources" / "adapter_policy_default.json" + + logger.debug("AdapterPolicyService initialized with custom=%s, default=%s", + self.custom_policy_path, self.default_policy_path) + + def __repr__(self) -> str: + return f"AdapterPolicyService(custom={self.custom_policy_path})" + + def get_adapter_policy(self) -> Dict[str, Any]: + """Get the current adapter policy (custom or default). + + Returns: + Dictionary containing policy data and source + """ + for source, policy_path in [("custom", self.custom_policy_path), + ("default", self.default_policy_path)]: + try: + policy_data = read_json(policy_path) + logger.debug("Loaded %s adapter policy from %s (%d keys)", + source, policy_path, + len(policy_data) if isinstance(policy_data, dict) else 0) + return {"policy": policy_data, "source": source} + except FileNotFoundError: + continue + except (OSError, ValueError) as e: + logger.exception("Failed to load %s adapter policy", source) + raise AdapterPolicyNotFoundError(source, details={"error": str(e)}) + + raise AdapterPolicyNotFoundError("default", details={"error": "No policy file found"}) + + def save_adapter_policy(self, policy: Dict[str, Any]) -> None: + """Save adapter policy to custom policy file. + + Args: + policy: Policy data to save + """ + self.custom_policy_path.parent.mkdir(parents=True, exist_ok=True) + try: + write_json(self.custom_policy_path, policy) + logger.info("Saved adapter policy to %s", self.custom_policy_path) + except Exception as e: + logger.exception("Failed to save adapter policy") + raise + + def delete_adapter_policy(self) -> None: + """Delete custom adapter policy (reverts to default).""" + try: + self.custom_policy_path.unlink() + logger.info("Deleted custom adapter policy: %s", self.custom_policy_path) + except FileNotFoundError: + logger.warning("Custom adapter policy does not exist, nothing to delete") diff --git a/utils/gui/backend/services/catalog_editor_service.py b/utils/gui/backend/services/catalog_editor_service.py new file mode 100644 index 0000000000..8c92d7b7ec --- /dev/null +++ b/utils/gui/backend/services/catalog_editor_service.py @@ -0,0 +1,289 @@ +import re +import shutil +import logging +import os +from datetime import datetime +from typing import Optional + +from ..models.catalog_schemas import ( + CatalogRoot, + CatalogInner, + BaseOS, + Infrastructure, +) +from ..utils.file_io import read_json, write_json_atomic +from ..config.settings import get_settings + + +logger = logging.getLogger(__name__) + + +class CatalogEditorService: + # Path constants + BACKUP_DIR_RELATIVE = "build_stream/core/catalog/test_fixtures/.backup" + DEFAULT_CATALOG_PATH = "build_stream/core/catalog/test_fixtures/catalog_rhel.json" + # Regex constants + VERSION_PATTERN = r"(?:[-_]?v\d+(?:[-.]\d+)*$|(?:^|[-_])\d+(?:[-.]\d+)*$)" + + def __init__(self, settings=None, app_state=None): + """Initialize the catalog editor service. + + Args: + settings: Optional settings instance. If None, uses default. + app_state: Optional FastAPI app.state for shared catalog storage. + """ + self.settings = settings or get_settings() + self.base_dir = self.settings.base_dir + # Don't set a default catalog path - catalog is kept in memory + # Only save when user explicitly clicks Save or Export JSON + self.catalog_path = None + self.backup_dir = ( + self.base_dir / self.BACKUP_DIR_RELATIVE + ) + # Use app_state for shared catalog storage (if available) + self.app_state = app_state + self._catalog: Optional[CatalogRoot] = None + + def __repr__(self) -> str: + return f"CatalogEditorService(base_dir={self.base_dir}, catalog_path={self.catalog_path})" + + def get_catalog(self) -> CatalogRoot: + """Get the catalog (in-memory, no file load required).""" + if self.app_state and hasattr(self.app_state, 'catalog'): + catalog = self.app_state.catalog + if catalog is None: + # Start with empty catalog if none exists + catalog = self._create_empty_catalog() + self.app_state.catalog = catalog + logger.info("Created new empty catalog in app.state") + return catalog + else: + # Fallback to instance catalog for backward compatibility + if self._catalog is None: + self._catalog = self._create_empty_catalog() + logger.info("Created new empty catalog in instance") + return self._catalog + + def get_inner(self) -> CatalogInner: + """Convenience: return the inner catalog data.""" + return self.get_catalog().Catalog + + def set_catalog(self, catalog: CatalogRoot) -> None: + """Set the catalog in-memory (no disk save).""" + self._catalog = catalog + if self.app_state and hasattr(self.app_state, 'catalog'): + self.app_state.catalog = catalog + + def save_catalog(self, catalog: CatalogRoot, path: str = None) -> None: + """Save catalog with atomic write and backup. + + Args: + catalog: The catalog to save + path: Optional path to save to. If None, uses default catalog_path + """ + save_path = path or self.catalog_path + if save_path is None: + # Use a default path if none specified + save_path = str( + self.base_dir / self.DEFAULT_CATALOG_PATH + ) + + # Prevent path traversal attacks + resolved = os.path.realpath(save_path) + if not resolved.startswith(str(self.base_dir.resolve())): + raise ValueError(f"Save path outside allowed directory: {save_path!r}") + + catalog_dict = catalog.model_dump(mode="json", exclude_none=True) + write_json_atomic(save_path, catalog_dict) + # Update app_state.catalog if available + if self.app_state and hasattr(self.app_state, 'catalog'): + self.app_state.catalog = catalog + # Update instance catalog to ensure refetch gets updated data + self._catalog = catalog + logger.info("Catalog saved to %s", save_path) + + def validate_catalog(self, catalog: CatalogRoot) -> dict: + """Validate catalog structure and return validation results (L1 + L2).""" + errors: list[str] = [] + warnings: list[str] = [] + inner = catalog.Catalog + + # Check for duplicate package IDs across categories + all_ids_list = ( + list(inner.FunctionalPackages.keys()) + + list(inner.OSPackages.keys()) + + list(inner.InfrastructurePackages.keys()) + ) + seen = set() + duplicates = set() + for pid in all_ids_list: + if pid in seen: + duplicates.add(pid) + seen.add(pid) + if duplicates: + errors.append( + f"Duplicate package IDs across categories: " + f"{', '.join(sorted(duplicates))}" + ) + + # Warn about packages with no Sources + for pkg_id, pkg in inner.FunctionalPackages.items(): + if pkg.Sources is None and pkg.Type not in ( + "pip_module", + "image", + ): + warnings.append( + f"Functional package '{pkg_id}' has no Sources" + ) + + # L2 validation using CatalogValidationService (handles package references) + try: + from .catalog_validation_service import CatalogValidationService + # Perform L2 business logic validation + l2_service = CatalogValidationService() + catalog_dict = catalog.model_dump(mode="json", exclude_none=True) + l2_errors = l2_service.validate_catalog(catalog_dict) + + for l2_error in l2_errors: + if l2_error.level == 'error': + errors.append(f"[L2] {l2_error.code}: {l2_error.message}") + else: + warnings.append(f"[L2] {l2_error.code}: {l2_error.message}") + except ImportError as e: + logger.warning("L2 validation service not available: %s", e) + except (ValueError, TypeError, AttributeError) as e: + logger.error("L2 validation failed: %s", e) + # Don't fail the entire validation if L2 fails + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + def generate_package_id( + self, + package_name: str, + category: str = "functional", + ) -> str: + """ + Generate human-readable package ID with cross-category + collision detection matching the _1 suffix pattern used + in the real catalog. + """ + # Sanitize the base name: lowercase, no spaces, normalize slashes for safety + base_id = package_name.lower().replace(" ", "-").replace("/", "-") + + # Remove version-like suffixes from the original package name to get a + # stable package ID (e.g. "csi-powerscale-v2.17.0" -> "csi-powerscale"). + # Slashes are preserved in the candidate so image package IDs stay intact. + candidate = re.sub(self.VERSION_PATTERN, "", package_name, flags=re.IGNORECASE) + if not candidate: + candidate = re.sub(self.VERSION_PATTERN, "", base_id, flags=re.IGNORECASE) + if not candidate: + candidate = package_name + + # Check for collisions across ALL categories + inner = self.get_inner() + existing_ids = ( + set(inner.FunctionalPackages.keys()) + | set(inner.OSPackages.keys()) + | set(inner.InfrastructurePackages.keys()) + ) + + if candidate not in existing_ids: + return candidate + + # Handle collisions with _N suffix + counter = 1 + while f"{candidate}_{counter}" in existing_ids: + counter += 1 + return f"{candidate}_{counter}" + + def _create_empty_catalog(self) -> CatalogRoot: + """Create empty catalog structure matching real format.""" + return CatalogRoot( + Catalog=CatalogInner( + Name="Catalog", + Version="1.0", + Identifier="image-build", + # CatalogSchemaVersion: Not set for Schema 1.0 compatibility + FunctionalLayer=[], + BaseOS=[ + BaseOS( + Name="RHEL", Version="10.0", osPackages=[] + ) + ], + Infrastructure=[ + Infrastructure( + Name="csi", InfrastructurePackages=[] + ) + ], + FunctionalPackages={}, + OSPackages={}, + InfrastructurePackages={}, + ) + ) + + def _create_backup(self) -> None: + """Create timestamped backup of current catalog.""" + if self.catalog_path is None: + return # No catalog to backup + self.backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = ( + self.backup_dir / f"catalog_rhel_{timestamp}.json" + ) + + if self.catalog_path.exists(): + shutil.copy2(self.catalog_path, backup_path) + + def list_catalog_presets(self) -> list: + """List available catalog preset files from examples/catalog folder. + + Returns: + List of catalog preset file information + """ + examples_dir = self.settings.examples_dir / "catalog" + + if not examples_dir.exists(): + logger.warning("Examples catalog directory not found: %s", examples_dir) + return [] + + catalog_files = [] + for file_path in examples_dir.glob("*.json"): + catalog_files.append({ + "name": file_path.stem, + "filename": file_path.name, + }) + + # Sort alphabetically + catalog_files.sort(key=lambda x: x["name"]) + logger.info("Found %d catalog preset files", len(catalog_files)) + return catalog_files + + def load_catalog_preset(self, filename: str) -> dict: + """Load a specific catalog preset file. + + Args: + filename: Name of the preset file to load + + Returns: + Catalog data as dictionary + + Raises: + FileNotFoundError: If preset file not found + """ + examples_dir = (self.settings.examples_dir / "catalog").resolve() + catalog_path = (examples_dir / filename).resolve() + + # Prevent path traversal attacks + if not str(catalog_path).startswith(str(examples_dir)): + raise ValueError(f"Invalid filename: {filename!r}") + + if not catalog_path.exists(): + raise FileNotFoundError(f"Catalog preset not found: {filename}") + + catalog_data = read_json(catalog_path) + logger.info("Loaded catalog preset: %s", filename) + return catalog_data diff --git a/utils/gui/backend/services/catalog_validation_service.py b/utils/gui/backend/services/catalog_validation_service.py new file mode 100644 index 0000000000..60bd678a6e --- /dev/null +++ b/utils/gui/backend/services/catalog_validation_service.py @@ -0,0 +1,171 @@ +"""Service for L2 validation of catalog content. + +This service performs business logic and domain rules validation +beyond the L1 schema validation performed by ParseCatalog. + +L2 Validation Rules: +- Package references must exist in package dictionaries +- Architecture consistency across packages and layers +- Required fields per package type +- Functional layer package references must be valid +- BaseOS package references must be valid +- Infrastructure package references must be valid +""" + +import logging +from typing import Dict, List, Set, Any + +logger = logging.getLogger(__name__) + + +class CatalogValidationError: + """Represents a single validation error.""" + + def __init__(self, level: str, code: str, message: str, path: str = ""): + self.level = level # 'error' or 'warning' + self.code = code + self.message = message + self.path = path + + def to_dict(self) -> Dict[str, str]: + return { + 'level': self.level, + 'code': self.code, + 'message': self.message, + 'path': self.path + } + + def __repr__(self) -> str: + return f"CatalogValidationError(level={self.level!r}, code={self.code!r}, path={self.path!r})" + + +class CatalogValidationService: + """Service for L2 validation of catalog content.""" + + def __init__(self): + logger.debug("CatalogValidationService initialized") + + def __repr__(self) -> str: + return "CatalogValidationService()" + + def validate_catalog(self, catalog: Dict[str, Any]) -> List[CatalogValidationError]: + """Perform L2 validation on catalog data. + + Args: + catalog: Catalog dictionary (parsed from JSON) + + Returns: + List of validation errors + """ + errors: List[CatalogValidationError] = [] + + if 'Catalog' not in catalog: + errors.append(CatalogValidationError( + level='error', + code='MISSING_CATALOG', + message='Catalog section is missing' + )) + return errors + + inner = catalog['Catalog'] + + # Validate package references across all sections + errors.extend(self._validate_package_references( + inner, 'FunctionalLayer', 'FunctionalPackages', + 'FunctionalPackages', 'Functional layer' + )) + # Note: 'osPackages' uses camelCase per catalog schema, unlike other sections + errors.extend(self._validate_package_references( + inner, 'BaseOS', 'OSPackages', + 'osPackages', 'BaseOS' + )) + errors.extend(self._validate_package_references( + inner, 'Infrastructure', 'InfrastructurePackages', + 'InfrastructurePackages', 'Infrastructure' + )) + + # Validate architecture consistency + errors.extend(self._validate_architecture_consistency(inner)) + + logger.info("L2 validation completed with %d errors", len(errors)) + return errors + + def _validate_package_references( + self, + inner: Dict[str, Any], + section_key: str, + packages_key: str, + ref_field: str, + label: str, + ) -> List[CatalogValidationError]: + """Validate that package references in a section exist in the package dict. + + Args: + section_key: Key for the section list (e.g., 'FunctionalLayer') + packages_key: Key for the package dict (e.g., 'FunctionalPackages') + ref_field: Field within each item that holds package IDs + label: Human-readable label for error messages + """ + errors: List[CatalogValidationError] = [] + + if section_key not in inner or packages_key not in inner: + return errors + + valid_ids = set(inner[packages_key].keys()) + + for item in inner[section_key]: + item_name = item.get('Name', 'unknown') + for pkg_id in item.get(ref_field, []): + if pkg_id not in valid_ids: + errors.append(CatalogValidationError( + level='error', + code='INVALID_PACKAGE_REFERENCE', + message=f'{label} "{item_name}" references non-existent package: {pkg_id}', + path=f'Catalog.{section_key}.{item_name}.{ref_field}', + )) + + return errors + + def _validate_architecture_consistency(self, inner: Dict[str, Any]) -> List[CatalogValidationError]: + """Validate architecture consistency across packages and layers.""" + errors: List[CatalogValidationError] = [] + + # Collect all architectures from packages + all_architectures: Set[str] = set() + + for pkg_type in ['FunctionalPackages', 'OSPackages', 'InfrastructurePackages']: + if pkg_type not in inner: + continue + + for pkg in inner[pkg_type].values(): + arch_list = pkg.get('Architecture', []) + # Normalize to list + if isinstance(arch_list, str): + arch_list = [arch_list] + all_architectures.update(arch_list) + + # Validate architectures across all sections + _ARCH_SECTIONS = ('FunctionalLayer', 'BaseOS', 'Infrastructure') + + for section_key in _ARCH_SECTIONS: + if section_key not in inner: + continue + + for item in inner[section_key]: + item_name = item.get('Name', 'unknown') + item_arch = item.get('Architecture', []) + + # Normalize to list + if isinstance(item_arch, str): + item_arch = [item_arch] + + for arch in item_arch: + if arch not in all_architectures: + errors.append(CatalogValidationError( + level='warning', + code='ARCHITECTURE_MISMATCH', + message=f'{section_key} "{item_name}" uses architecture {arch} which is not found in any package', + path=f'Catalog.{section_key}.{item_name}.Architecture' + )) + + return errors diff --git a/utils/gui/backend/services/config_file_generators.py b/utils/gui/backend/services/config_file_generators.py new file mode 100644 index 0000000000..cfa51d9c0d --- /dev/null +++ b/utils/gui/backend/services/config_file_generators.py @@ -0,0 +1,894 @@ +"""Configuration file generators for wizard data. +Each generator function creates a specific YAML configuration file from wizard data. +""" + +import copy +import csv +import json +import logging +import yaml +from pathlib import Path +from typing import Any, Dict, Callable + +from ..config.defaults import ( + get_build_stream_config_defaults, + get_discovery_config_defaults, + get_telemetry_config_defaults, + get_telemetry_storage_config_defaults, +) + + +logger = logging.getLogger(__name__) + +# Optional repo fields that should be omitted when empty +OPTIONAL_REPO_FIELDS = {'policy', 'caching', 'sslcacert', 'sslclientkey', 'sslclientcert'} + +# RHEL repo keys for generate_local_repo_config +_RHEL_REPO_KEYS = ( + "rhel_os_url_x86_64", "rhel_os_url_aarch64", + "omnia_repo_url_rhel_x86_64", "omnia_repo_url_rhel_aarch64", + "rhel_subscription_repo_config_x86_64", "rhel_subscription_repo_config_aarch64", + "additional_repos_x86_64", "additional_repos_aarch64", +) + +# PXE CSV header columns +_PXE_CSV_COLUMNS = ( + "FUNCTIONAL_GROUP_NAME", "GROUP_NAME", "SERVICE_TAG", + "PARENT_SERVICE_TAG", "HOSTNAME", "ADMIN_MAC", "ADMIN_IP", + "BMC_MAC", "BMC_IP", "IB_NIC_NAME", "IB_IP", +) + + +def _yaml_escape(value: str) -> str: + """Escape a string for safe YAML double-quoted output.""" + return value.replace('\\', '\\\\').replace('"', '\\"') + + +def format_repo_entry(item: dict) -> str: + """Format a repo entry as inline YAML, omitting empty optional fields.""" + parts = [] + for k, v in item.items(): + # Skip empty optional fields + if k in OPTIONAL_REPO_FIELDS and (v is None or v == ''): + continue + # Format value + if isinstance(v, bool): + parts.append(f'{k}: {str(v).lower()}') + elif isinstance(v, str) and v != '': + parts.append(f'{k}: "{_yaml_escape(v)}"') + elif v == '': + parts.append(f'{k}: ""') + else: + parts.append(f'{k}: "{_yaml_escape(str(v))}"') + return f"{{{', '.join(parts)}}}" + + +def has_meaningful_data(data: Any) -> bool: + """Check if data has any non-empty, non-default values. + + Returns True if: + - Non-empty list with at least one item + - Non-empty string + - Any number (including 0) + - True boolean + - Nested dict with meaningful data + """ + if data is None: + return False + if isinstance(data, dict): + for key, value in data.items(): + if has_meaningful_data(value): + return True + return False + elif isinstance(data, list): + for item in data: + if has_meaningful_data(item): + return True + return False + elif isinstance(data, bool): + return data + elif isinstance(data, (int, float)): + return True # Treat 0 as meaningful (port 0, timeout 0, etc.) + elif isinstance(data, str): + return len(data.strip()) > 0 + return False + + +def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: + """Recursively merge override into base. + + Values from override win. Existing keys in base that are not present in + override are preserved. + """ + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_merge(base[key], value) + else: + base[key] = value + + +def _flatten_csm_metrics_powerscale_storage(config: Dict[str, Any]) -> None: + """Flatten csm_metrics_powerscale_storage if the frontend sends a resources wrapper. + + The reference YAML and backend schema expect requests/limits directly under + csm_metrics_powerscale_storage, while the frontend form uses a resources + wrapper for UI consistency. This normalization merges the resources block + into the top-level section and removes the wrapper. + """ + section = config.get("csm_metrics_powerscale_storage") + if not isinstance(section, dict) or "resources" not in section: + return + resources = section.pop("resources") + if not isinstance(resources, dict): + return + for key, value in resources.items(): + if isinstance(value, dict) and isinstance(section.get(key), dict): + section[key].update(value) + else: + section[key] = value + + +def generate_omnia_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate omnia_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + omnia_config = {} + + # Only include slurm_cluster if it has meaningful data + slurm_clusters = wizard_data.get("slurm_cluster", []) + slurm_meaningful = any( + cluster.get("cluster_name") and len(str(cluster.get("cluster_name", "")).strip()) > 0 + for cluster in slurm_clusters if isinstance(cluster, dict) + ) + if slurm_meaningful: + omnia_config["slurm_cluster"] = slurm_clusters + + # Only include service_k8s_cluster if it has meaningful data + k8s_clusters = wizard_data.get("service_k8s_cluster", []) + k8s_meaningful = any( + cluster.get("cluster_name") and len(str(cluster.get("cluster_name", "")).strip()) > 0 and + cluster.get("deployment") is not None and cluster.get("deployment") != "" + for cluster in k8s_clusters if isinstance(cluster, dict) + ) + if k8s_meaningful: + omnia_config["service_k8s_cluster"] = k8s_clusters + + if omnia_config: + _write_config_file(input_dir / "omnia_config.yml", omnia_config, quote_all_strings=True) + + +def generate_network_spec(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate network_spec.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + networks = wizard_data.get("Networks", []) + + # Only generate if networks has meaningful data + if has_meaningful_data(networks): + # Filter out empty network entries + filtered_networks = [] + for network in networks: + if isinstance(network, dict) and has_meaningful_data(network): + # IB network is optional; skip if no subnet is configured + if network.get('ib_network'): + ib_subnet = str(network['ib_network'].get('subnet', '')).strip() + if not ib_subnet: + continue + filtered_networks.append(network) + + if filtered_networks: + _write_config_file( + input_dir / "network_spec.yml", + {"Networks": filtered_networks}, + quote_all_strings=True + ) + else: + logger.info("Skipped network_spec.yml (no meaningful network data)") + else: + logger.info("Skipped network_spec.yml (no meaningful data)") + + +def _convert_string_bools_to_bools(data: Any) -> Any: + """Recursively convert string boolean values to actual boolean types.""" + if isinstance(data, dict): + return {k: _convert_string_bools_to_bools(v) for k, v in data.items()} + elif isinstance(data, list): + return [_convert_string_bools_to_bools(item) for item in data] + elif isinstance(data, str): + if data.lower() in ('true', 'yes', 'on'): + return True + elif data.lower() in ('false', 'no', 'off'): + return False + return data + return data + + +def _clean_storage_entries( + entries: list, + required_key: str, + array_fields: set, + skip_fn: Callable[[str, dict], bool] = lambda k, e: False, +) -> list: + """Clean and filter storage config entries. + + Args: + entries: List of entry dicts to clean + required_key: Key that must be present for entry to be included + array_fields: Set of keys whose string values should be split on commas + skip_fn: Optional function to skip certain fields based on key and entry + + Returns: + List of cleaned entry dicts + """ + result = [] + for entry in entries: + if not isinstance(entry, dict) or not entry.get(required_key): + continue + cleaned = {} + for k, v in entry.items(): + if v is None or v == '' or v == [] or v == {}: + continue + if skip_fn(k, entry): + continue + if k in array_fields and isinstance(v, str): + v = [x.strip() for x in v.split(',')] + cleaned[k] = v + result.append(cleaned) + return result + + +def _write_config_file( + path: Path, + config: Dict[str, Any], + quote_all_strings: bool = False, + preserve_octal_mode: bool = False, +) -> None: + """Write a config dict to a YAML file with consistent formatting.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + for key, value in config.items(): + f.write(f"{key}:") + if isinstance(value, dict) and not value: + f.write(" {}\n") + elif isinstance(value, (list, dict)): + f.write("\n") + _write_yaml_value(f, value, 1, quote_all_strings, preserve_octal_mode) + else: + f.write(" ") + _write_yaml_value(f, value, 0, quote_all_strings, preserve_octal_mode) + logger.info("Generated %s", path.name) + + +def _should_quote_string(value: str) -> bool: + """Determine if a string value should be quoted based on content.""" + if value == "": + return True + # Quote YAML boolean-like strings + if value.lower() in ('true', 'false', 'yes', 'no', 'on', 'off', 'null'): + return True + # Quote if contains special characters + if any(char in value for char in ['/', '-', ':', '.', ' ', '#', '{', '}', '[', ']', ',', '&', '*', '?', '|', '>', '!', '%', '@', '`']): + return True + # Quote if looks like a number but is a string + if value.replace('.', '').replace('-', '').isdigit(): + return True + # Quote storage sizes with units (Gi, Mi, Ki, Ti, G, M, K, T, GB, MB, KB, TB) + if any(value.endswith(unit) for unit in ['Gi', 'Mi', 'Ki', 'Ti', 'G', 'M', 'K', 'T', 'GB', 'MB', 'KB', 'TB', 'm']): + return True + return False + + +def _write_yaml_value(f, value, indent, quote_all_strings=False, preserve_octal_mode=False): + """Recursively write YAML value with proper indentation and quoting.""" + indent_str = " " * indent + if value is None: + f.write(f'{indent_str}""\n') + elif isinstance(value, bool): + f.write(f"{indent_str}{str(value).lower()}\n") + elif isinstance(value, int) or isinstance(value, float): + f.write(f"{indent_str}{value}\n") + elif isinstance(value, str): + # Handle multi-line strings with block scalar + if '\n' in value or value.startswith('|'): + # Strip YAML block scalar indicator if user included it + clean_value = value + if clean_value.startswith('|'): + clean_value = clean_value[1:] # remove leading | + clean_value = clean_value.strip('\n') # remove leading/trailing newlines + + f.write(f"{indent_str}|\n") + for line in clean_value.split('\n'): + # Preserve relative indentation but add base indent + stripped = line.rstrip() + if stripped: + f.write(f"{indent_str} {stripped}\n") + else: + f.write("\n") # empty line in block scalar + return + # Try to coerce numeric strings to numbers before quoting + # NOTE: Intentional type coercion — numeric strings from the UI are written + # as YAML integers/floats to match reference file format. If this causes + # issues, set quote_all_strings=True for the affected config. + # Skip coercion for: + # 1. Octal mode values (preserve_octal_mode flag) - "0755" format + # 2. Strings that are all digits but start with '0' - likely octal/permissions + # 3. Single digit strings that should stay as strings (dump_freq, fsck_pass) + should_preserve_string = ( + preserve_octal_mode or + (value.startswith('0') and value.isdigit() and len(value) > 1) or + (value.isdigit() and len(value) == 1) + ) + if not quote_all_strings and not should_preserve_string: + try: + num = int(value) + f.write(f"{indent_str}{num}\n") + return + except ValueError: + try: + num = float(value) + f.write(f"{indent_str}{num}\n") + return + except ValueError: + pass + if quote_all_strings or _should_quote_string(value): + # Use _yaml_escape for proper backslash and quote escaping + escaped = _yaml_escape(value) + f.write(f'{indent_str}"{escaped}"\n') + else: + f.write(f"{indent_str}{value}\n") + elif isinstance(value, list): + if not value: + f.write(f"{indent_str}[]\n") + else: + for item in value: + if isinstance(item, dict): + f.write(f"{indent_str}-") + first_key = True + for k, v in item.items(): + if first_key: + f.write(f" {k}:") + first_key = False + else: + f.write(f"{indent_str} {k}:") + if isinstance(v, (list, dict)): + f.write("\n") + _write_yaml_value(f, v, indent + 2, quote_all_strings, preserve_octal_mode) + else: + f.write(" ") + _write_yaml_value(f, v, 0, quote_all_strings, preserve_octal_mode) + else: + f.write(f"{indent_str}- ") + _write_yaml_value(f, item, 0, quote_all_strings, preserve_octal_mode) + elif isinstance(value, dict): + if not value: + f.write(f"{indent_str}{{}}\n") + else: + for k, v in value.items(): + f.write(f"{indent_str}{k}:") + if isinstance(v, (list, dict)): + f.write("\n") + _write_yaml_value(f, v, indent + 1, quote_all_strings, preserve_octal_mode) + else: + f.write(" ") + _write_yaml_value(f, v, 0, quote_all_strings, preserve_octal_mode) + + +def generate_gitlab_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate gitlab_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + gitlab_host = wizard_data.get("gitlab_host", "") + if not has_meaningful_data(gitlab_host): + logger.info("Skipped gitlab_config.yml (gitlab not enabled)") + return + + gitlab_config = { + "gitlab_host": gitlab_host, + "gitlab_project_name": wizard_data.get("gitlab_project_name", ""), + "gitlab_project_visibility": wizard_data.get("gitlab_project_visibility", "private"), + "gitlab_default_branch": wizard_data.get("gitlab_default_branch", "main"), + "gitlab_https_port": wizard_data.get("gitlab_https_port", 443), + "gitlab_min_storage_gb": wizard_data.get("gitlab_min_storage_gb", 20), + "gitlab_min_memory_gb": wizard_data.get("gitlab_min_memory_gb", 4), + "gitlab_min_cpu_cores": wizard_data.get("gitlab_min_cpu_cores", 2), + "gitlab_puma_workers": wizard_data.get("gitlab_puma_workers", 2), + "gitlab_sidekiq_concurrency": wizard_data.get("gitlab_sidekiq_concurrency", 10), + } + + _write_config_file(input_dir / "gitlab_config.yml", gitlab_config, quote_all_strings=True) + + +def generate_build_stream_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate build_stream_config.yml from wizard data. + + Always emitted; when build stream is not enabled, a disabled default + configuration is written. + """ + build_stream_config = copy.deepcopy(get_build_stream_config_defaults()) + user_data = { + k: v + for k, v in wizard_data.items() + if k in build_stream_config and v is not None + } + _deep_merge(build_stream_config, user_data) + _write_config_file(input_dir / "build_stream_config.yml", build_stream_config, quote_all_strings=True) + + +def generate_discovery_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate discovery_config.yml from wizard data. + + Always emitted; when BMC discovery is not enabled, a disabled default + configuration is written. + """ + discovery_config = copy.deepcopy(get_discovery_config_defaults()) + user_data = { + k: v + for k, v in wizard_data.items() + if k in discovery_config and v is not None + } + _deep_merge(discovery_config, user_data) + _write_config_file(input_dir / "discovery_config.yml", discovery_config, quote_all_strings=False) + + +def generate_high_availability_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate high_availability_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + # Only generate the HA config when Kubernetes is part of the selected cluster type + k8s_clusters = wizard_data.get("service_k8s_cluster", []) + k8s_selected = any( + isinstance(cluster, dict) + and cluster.get("cluster_name") + and len(str(cluster.get("cluster_name", "")).strip()) > 0 + and cluster.get("deployment") is not None + and cluster.get("deployment") != "" + for cluster in k8s_clusters + ) + if not k8s_selected: + logger.info("Skipped high_availability_config.yml (no K8s cluster configured)") + return + + # If HA is enabled, use the user-provided data; otherwise emit a disabled placeholder + if wizard_data.get("enable_ha"): + service_k8s_cluster_ha = wizard_data.get("service_k8s_cluster_ha", []) + else: + service_k8s_cluster_ha = [ + { + "cluster_name": "", + "enable_k8s_ha": False, + "virtual_ip_address": "", + } + ] + + ha_config = { + "service_k8s_cluster_ha": service_k8s_cluster_ha + } + + _write_config_file(input_dir / "high_availability_config.yml", ha_config, quote_all_strings=False) + + +def _build_local_repo_config_for_os(data: Dict[str, Any], os_type: str) -> Dict[str, Any]: + """Build local_repo_config content for a single OS (rhel or ubuntu). + + Args: + data: OS-specific form data + os_type: 'rhel' or 'ubuntu' + + Returns: + Dictionary with local repo config entries + """ + local_repo_config: Dict[str, Any] = {} + os_cap = os_type.capitalize() + + show_user_registry = data.get("_ui_showUserRegistry", False) + show_user_repos = data.get("_ui_showUserRepos", False) + show_additional_repos = data.get("_ui_showAdditionalRepos", False) + show_os_repos = data.get(f"_ui_show{os_cap}Repos", False) + show_os_subscription = data.get(f"_ui_show{os_cap}Subscription", False) + + if show_user_registry and has_meaningful_data(data.get("user_registry")): + local_repo_config["user_registry"] = data.get("user_registry") + + if show_user_repos: + if has_meaningful_data(data.get("user_repo_url_x86_64")): + local_repo_config["user_repo_url_x86_64"] = data.get("user_repo_url_x86_64") + if has_meaningful_data(data.get("user_repo_url_aarch64")): + local_repo_config["user_repo_url_aarch64"] = data.get("user_repo_url_aarch64") + + if show_additional_repos: + if has_meaningful_data(data.get("additional_repos_x86_64")): + local_repo_config["additional_repos_x86_64"] = data.get("additional_repos_x86_64") + if has_meaningful_data(data.get("additional_repos_aarch64")): + local_repo_config["additional_repos_aarch64"] = data.get("additional_repos_aarch64") + + if show_os_repos: + os_repo_keys = ( + f"{os_type}_os_url_x86_64", + f"{os_type}_os_url_aarch64", + ) + for key in os_repo_keys: + value = data.get(key) + if has_meaningful_data(value): + local_repo_config[key] = value + + # Omnia repos are on a separate tab with no enable toggle, so emit them + # whenever they contain meaningful data. + omnia_repo_keys = ( + f"omnia_repo_url_{os_type}_x86_64", + f"omnia_repo_url_{os_type}_aarch64", + ) + for key in omnia_repo_keys: + value = data.get(key) + if has_meaningful_data(value): + local_repo_config[key] = value + + if show_os_subscription: + subscription_keys = ( + f"{os_type}_subscription_repo_config_x86_64", + f"{os_type}_subscription_repo_config_aarch64", + ) + for key in subscription_keys: + value = data.get(key) + if has_meaningful_data(value): + local_repo_config[key] = value + + return local_repo_config + + +def _write_local_repo_config(local_repo_config: Dict[str, Any], input_dir: Path) -> None: + """Write local_repo_config.yml to disk. + + Args: + local_repo_config: Dictionary containing local repo configuration + input_dir: Directory where config files should be written + """ + if not local_repo_config: + logger.info("Skipped local_repo_config.yml (no meaningful data or sections not enabled)") + return + + local_repo_config = _convert_string_bools_to_bools(local_repo_config) + local_repo_config_path = input_dir / "local_repo_config.yml" + local_repo_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(local_repo_config_path, 'w', encoding='utf-8') as f: + for key, value in local_repo_config.items(): + f.write(f"{key}:\n") + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + entry_str = format_repo_entry(item) + f.write(f" - {entry_str}\n") + else: + f.write(f" - {item}\n") + else: + f.write(f" {value}\n") + logger.info("Generated local_repo_config.yml at %s", local_repo_config_path) + + +def generate_local_repo_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate local_repo_config.yml from wizard data. + + Supports both legacy wizard payloads and the new management payload + that separates RHEL and Ubuntu configuration under top-level keys. + + Args: + wizard_data: Dictionary containing wizard or management form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + # New management payload with per-OS sections + if "rhel" in wizard_data or "ubuntu" in wizard_data: + merged_config: Dict[str, Any] = {} + for os_type in ("rhel", "ubuntu"): + os_data = wizard_data.get(os_type, {}) + if not os_data: + continue + os_config = _build_local_repo_config_for_os(os_data, os_type) + for key, value in os_config.items(): + if key in merged_config and isinstance(value, list) and isinstance(merged_config[key], list): + seen = {json.dumps(v, sort_keys=True) for v in merged_config[key]} + for item in value: + item_key = json.dumps(item, sort_keys=True) + if item_key not in seen: + merged_config[key].append(item) + seen.add(item_key) + else: + merged_config[key] = value + _write_local_repo_config(merged_config, input_dir) + return + + # Legacy single-OS (RHEL) wizard payload + local_repo_config = _build_local_repo_config_for_os(wizard_data, "rhel") + if has_meaningful_data(wizard_data.get("rhel_os_url_x86_64")) and not local_repo_config: + # Fallback: if no toggles are present, include legacy RHEL repo keys when meaningful + for key in _RHEL_REPO_KEYS: + value = wizard_data.get(key) + if has_meaningful_data(value): + local_repo_config[key] = value + _write_local_repo_config(local_repo_config, input_dir) + + +def generate_telemetry_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate telemetry_config.yml from wizard data. + + Always emitted; when no telemetry source is enabled, a disabled default + configuration is written. + """ + telemetry_config = copy.deepcopy(get_telemetry_config_defaults()) + user_data = { + k: v + for k, v in wizard_data.items() + if k in telemetry_config and isinstance(v, dict) + } + _deep_merge(telemetry_config, user_data) + _write_config_file(input_dir / "telemetry_config.yml", telemetry_config, quote_all_strings=True) + + +def generate_telemetry_storage_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate telemetry_storage_config.yml from wizard data. + + Always emitted; when no telemetry source is enabled, a disabled default + configuration is written. + """ + telemetry_storage_config = copy.deepcopy(get_telemetry_storage_config_defaults()) + user_data = { + k: v + for k, v in wizard_data.items() + if k in telemetry_storage_config and isinstance(v, dict) + } + _deep_merge(telemetry_storage_config, user_data) + _flatten_csm_metrics_powerscale_storage(telemetry_storage_config) + _write_config_file(input_dir / "telemetry_storage_config.yml", telemetry_storage_config, quote_all_strings=False) + + +def _write_user_registry_credential(credentials: Any, input_dir: Path) -> None: + """Write user_registry_credential.yml to disk.""" + if not has_meaningful_data(credentials): + logger.info("Skipped user_registry_credential.yml (no meaningful credential data)") + return + + path = input_dir / "user_registry_credential.yml" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + f.write("user_registry_credential:\n") + for cred in credentials: + name = _yaml_escape(str(cred.get("name", ""))) + username = _yaml_escape(str(cred.get("username", ""))) + password = _yaml_escape(str(cred.get("password", ""))) + f.write(f' - {{name: "{name}", username: "{username}", password: "{password}"}}\n') + logger.info("Generated user_registry_credential.yml at %s", path) + + +def generate_user_registry_credential(wizard_data, input_dir, write_yaml_fn): + """Generate user_registry_credential.yml from wizard or management data.""" + if "rhel" in wizard_data or "ubuntu" in wizard_data: + merged_credentials = [] + seen = set() + for os_type in ("rhel", "ubuntu"): + os_data = wizard_data.get(os_type, {}) + if os_data.get("_ui_showCredentials", False): + credentials = os_data.get("user_registry_credential", []) + if has_meaningful_data(credentials): + for cred in credentials: + key = json.dumps(cred, sort_keys=True) + if key not in seen: + merged_credentials.append(cred) + seen.add(key) + _write_user_registry_credential(merged_credentials, input_dir) + return + + if not wizard_data.get("_ui_showCredentials", False): + logger.info("Skipped user_registry_credential.yml (credentials not enabled)") + return + + _write_user_registry_credential(wizard_data.get("user_registry_credential", []), input_dir) +def generate_pxe_mapping_file(wizard_data: Dict[str, Any], input_dir: Path, ensure_directory_fn: Callable) -> None: + """Generate pxe_mapping_file.csv from wizard data.""" + pxe_mapping_data = wizard_data.get("pxe_mapping_data") + + if pxe_mapping_data: + pxe_mapping_path = input_dir / "pxe_mapping_file.csv" + ensure_directory_fn(pxe_mapping_path.parent) + + # Write CSV header + with open(pxe_mapping_path, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + writer.writerow(_PXE_CSV_COLUMNS) + + # Write data rows + for row in pxe_mapping_data: + writer.writerow([row.get(col, "") for col in _PXE_CSV_COLUMNS]) + + logger.info("Generated pxe_mapping_file.csv at %s", pxe_mapping_path) + else: + logger.warning("No PXE mapping data provided, skipping pxe_mapping_file.csv generation") + + +def generate_provision_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate provision_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + dns_enabled = wizard_data.get("dns_enabled", False) + lease_time = wizard_data.get("default_lease_time", "") + language = wizard_data.get("language", "") + kernel_version = wizard_data.get("kernel_version_override", "") + cloud_init = wizard_data.get("additional_cloud_init_config_file", "") + pxe_mapping_data = wizard_data.get("pxe_mapping_data", []) + + if not ( + dns_enabled + or has_meaningful_data(lease_time) + or has_meaningful_data(kernel_version) + or has_meaningful_data(cloud_init) + or (language and language != "en_US.UTF-8") + or has_meaningful_data(pxe_mapping_data) + ): + logger.info("Skipped provision_config.yml (no meaningful data)") + return + + provision_config = { + "pxe_mapping_file_path": "input/pxe_mapping_file.csv", + "language": language or "en_US.UTF-8", + "default_lease_time": lease_time or "86400", + "dns_enabled": dns_enabled, + "kernel_version_override": kernel_version, + "additional_cloud_init_config_file": cloud_init, + } + + _write_config_file(input_dir / "provision_config.yml", provision_config, quote_all_strings=True) + + +def generate_storage_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate storage_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + storage_config = {} + + # Mounts section + mounts = wizard_data.get("mounts", []) + if has_meaningful_data(mounts): + filtered_mounts = _clean_storage_entries( + mounts, + "name", + {'functional_group_prefix', 'groups', 'node_mount_point'} + ) + if filtered_mounts: + storage_config["mounts"] = filtered_mounts + + # Mount params section + mount_params = wizard_data.get("mount_params", {}) + if has_meaningful_data(mount_params): + storage_config["mount_params"] = mount_params + + # PowerVault section + powervault = wizard_data.get("powervault_config", []) + if has_meaningful_data(powervault): + filtered_pv = _clean_storage_entries( + powervault, + "name", + {'functional_group_prefix', 'node_mount_point', 'ip'} + ) + if filtered_pv: + storage_config["powervault_config"] = filtered_pv + + # Swap section + swap = wizard_data.get("swap", []) + if has_meaningful_data(swap): + filtered_swap = _clean_storage_entries( + swap, + "filename", + {'functional_group_prefix'}, + skip_fn=lambda k, e: k == 'maxsize' and e.get('size') != 'auto' + ) + if filtered_swap: + storage_config["swap"] = filtered_swap + + # S3 section + s3 = wizard_data.get("s3_configurations", {}) + if has_meaningful_data(s3): + storage_config["s3_configurations"] = s3 + + if not storage_config: + logger.info("Skipped storage_config.yml (no meaningful data)") + return + + _write_config_file(input_dir / "storage_config.yml", storage_config, quote_all_strings=True, preserve_octal_mode=True) + + +def generate_additional_cloud_init(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate additional_cloud_init.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + cloud_init = {} + + # Common section + common_data = copy.deepcopy(wizard_data.get("cloud_init_common", {})) + if has_meaningful_data(common_data): + # Transform runcmd from objects to strings for YAML output + if "runcmd" in common_data and isinstance(common_data["runcmd"], list): + common_data["runcmd"] = [item.get("command", "") if isinstance(item, dict) else item for item in common_data["runcmd"]] + cloud_init["common"] = common_data + else: + cloud_init["common"] = {} + + # Groups section — transform array [{group_name, write_files, runcmd}] to {group_name: {write_files, runcmd}} + groups_data = copy.deepcopy(wizard_data.get("cloud_init_groups", [])) + groups_dict = {} + if isinstance(groups_data, list): + for group in groups_data: + if isinstance(group, dict) and group.get("group_name"): + name = group["group_name"] + entry = {} + if has_meaningful_data(group.get("write_files")): + entry["write_files"] = group["write_files"] + if has_meaningful_data(group.get("runcmd")): + # Transform runcmd from objects to strings for YAML output + runcmd_list = group["runcmd"] + if isinstance(runcmd_list, list): + entry["runcmd"] = [item.get("command", "") if isinstance(item, dict) else item for item in runcmd_list] + else: + entry["runcmd"] = runcmd_list + if entry: + groups_dict[name] = entry + + if groups_dict: + cloud_init["groups"] = groups_dict + else: + cloud_init["groups"] = {} + + # Only generate if there's any meaningful data + if not has_meaningful_data(cloud_init.get("common")) and not has_meaningful_data(cloud_init.get("groups")): + logger.info("Skipped additional_cloud_init.yml (no meaningful data)") + return + + _write_config_file(input_dir / "additional_cloud_init.yml", cloud_init, quote_all_strings=False) + + +def generate_security_config(wizard_data: Dict[str, Any], input_dir: Path, write_yaml_fn: Callable) -> None: + """Generate security_config.yml from wizard data. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + write_yaml_fn: Callback for writing YAML (required by generator registry interface, unused) + """ + security_config_data = wizard_data.get("security_config", {}) + + # Only generate if there's meaningful data + if not has_meaningful_data(security_config_data): + logger.info("Skipped security_config.yml (no meaningful data)") + return + + security_config = { + "ldap_connection_type": security_config_data.get("ldap_connection_type", "TLS") + } + + _write_config_file(input_dir / "security_config.yml", security_config, quote_all_strings=True) + diff --git a/utils/gui/backend/services/config_service.py b/utils/gui/backend/services/config_service.py new file mode 100644 index 0000000000..9ed12f973c --- /dev/null +++ b/utils/gui/backend/services/config_service.py @@ -0,0 +1,475 @@ +""" +Configuration Service for Config Editor Module + +Provides business logic for configuration management including bundles, +software config, PXE mapping, and deployment configs. +""" + +import re +from pathlib import Path +from typing import Dict, List, Optional, Any +import logging + +from ..config.settings import get_settings +from ..utils.file_io import read_json, write_json, read_yaml, write_yaml, read_csv, write_csv +from ..models.schemas import ( + SoftwareConfig, PXEFunctionalGroup +) +from ..core.exceptions import ( + BundleNotFoundError, + SoftwareConfigNotFoundError, + PXEMappingNotFoundError, + ConfigurationNotFoundError +) +from ..core.constants import ( + FUNCTIONAL_BUNDLES, INFRA_BUNDLES, OS_BUNDLES, ALL_KNOWN_BUNDLES +) + +logger = logging.getLogger(__name__) + + +class ConfigService: + """Service for managing OMNIA configuration files.""" + + # Path validation regex - allows only safe characters + _SAFE_PATH_RE = re.compile(r'^[a-zA-Z0-9._-]+$') + + # PXE mapping column names + _PXE_COLUMNS = ( + "functional_group_name", "group_name", "service_tag", + "parent_service_tag", "hostname", "admin_mac", "admin_ip", + "bmc_mac", "bmc_ip", "ib_nic_name", "ib_ip", + ) + + def _validate_path_component(self, value: str, name: str) -> None: + """Validate a path component to prevent path traversal attacks. + + Args: + value: Path component to validate + name: Name of the parameter (for error messages) + + Raises: + ValueError: If the value contains unsafe characters + """ + if not self._SAFE_PATH_RE.match(value): + raise ValueError(f"Invalid {name}: {value!r}") + + def __init__(self, settings=None): + """Initialize the service. + + Args: + settings: Optional settings instance. If None, uses default. + """ + self.settings = settings or get_settings() + self.input_dir = self.settings.output_dir + self.config_dir = self.settings.base_input_dir / "config" + + logger.info("ConfigService initialized with input_dir: %s", self.input_dir) + + def __repr__(self) -> str: + return f"ConfigService(input_dir={self.input_dir})" + + def get_available_bundles(self, architecture: str, version: str) -> List[str]: + """Get available bundle names for specific architecture and version. + + Args: + architecture: Architecture (x86_64 or aarch64) + version: OS version (e.g., "10.0") + + Returns: + List of available bundle names + """ + self._validate_path_component(architecture, "architecture") + self._validate_path_component(version, "version") + + bundle_dir = self.config_dir / architecture / "rhel" / version + try: + bundle_files = list(bundle_dir.glob("*.json")) + except (OSError, PermissionError) as e: + logger.warning("Failed to list bundle directory %s: %s", bundle_dir, e) + return [] + + # Use shared bundle classification constants + bundles = [] + for file in bundle_files: + # Extract bundle name using logic from OSPackageService._extract_bundle_name + bundle_name = file.stem + # Try matching a known bundle prefix + for name in sorted(ALL_KNOWN_BUNDLES, key=len, reverse=True): + if bundle_name == name: + bundle_name = name + break + # Handle version suffixes like _v1.35.1, _2.16.0, -1.0 + if bundle_name.startswith(name) and len(bundle_name) > len(name): + sep = bundle_name[len(name)] + if sep in ('_', '-'): + # Check if next char is 'v' for versioned pattern + if sep == '_' and len(bundle_name) > len(name) + 1 and bundle_name[len(name) + 1] == 'v': + bundle_name = name + break + # Otherwise treat as generic separator + bundle_name = name + break + bundles.append(bundle_name) + + return sorted(set(bundles)) + + def _resolve_bundle_file(self, bundle_dir: Path, bundle_name: str) -> Path: + """Find bundle file, handling version suffixes. + + Args: + bundle_dir: Directory containing bundle files + bundle_name: Base bundle name + + Returns: + Path to the bundle file + + Raises: + BundleNotFoundError: If no matching bundle file is found + """ + self._validate_path_component(bundle_name, "bundle_name") + + exact = bundle_dir / f"{bundle_name}.json" + if exact.exists(): + return exact + + # Try versioned patterns in order of specificity + for pattern in (f"{bundle_name}_v*.json", f"{bundle_name}_*.json"): + try: + matches = list(bundle_dir.glob(pattern)) + except (OSError, PermissionError) as e: + logger.warning("Failed to glob pattern %s in %s: %s", pattern, bundle_dir, e) + continue + + if matches: + if len(matches) > 1: + logger.warning( + "Multiple matches for %s in %s: %s", + bundle_name, bundle_dir, [m.name for m in matches], + ) + return matches[0] + + raise BundleNotFoundError(bundle_name) + + def get_bundle_config(self, architecture: str, version: str, bundle_name: str) -> Dict[str, Any]: + """Get bundle configuration for specific architecture, version, and bundle. + + Args: + architecture: Architecture (x86_64 or aarch64) + version: OS version + bundle_name: Bundle name + + Returns: + Bundle configuration as dictionary + """ + self._validate_path_component(architecture, "architecture") + self._validate_path_component(version, "version") + self._validate_path_component(bundle_name, "bundle_name") + + bundle_dir = self.config_dir / architecture / "rhel" / version + bundle_file = self._resolve_bundle_file(bundle_dir, bundle_name) + return read_json(bundle_file) + + def update_bundle_config(self, architecture: str, version: str, bundle_name: str, + config: Dict[str, Any]) -> None: + """Update bundle configuration. + + Args: + architecture: Architecture + version: OS version + bundle_name: Bundle name + config: Updated configuration + """ + self._validate_path_component(architecture, "architecture") + self._validate_path_component(version, "version") + self._validate_path_component(bundle_name, "bundle_name") + + bundle_dir = self.config_dir / architecture / "rhel" / version + bundle_file = self._resolve_bundle_file(bundle_dir, bundle_name) + write_json(bundle_file, config) + logger.info("Updated bundle config: %s", bundle_file) + + def get_software_config(self) -> SoftwareConfig: + """Get software configuration from software_config.json. + + Returns: + SoftwareConfig object + """ + software_config_file = self.input_dir / "software_config.json" + try: + data = read_json(software_config_file) + except FileNotFoundError: + logger.warning("Software config not found: %s", software_config_file) + raise SoftwareConfigNotFoundError(str(software_config_file)) + except OSError as exc: + logger.error("Error reading software config %s: %s", software_config_file, exc) + raise SoftwareConfigNotFoundError(str(software_config_file)) + return SoftwareConfig(**data) + + def update_software_config(self, config: SoftwareConfig) -> None: + """Update software configuration. + + Args: + config: Updated SoftwareConfig object + """ + software_config_file = self.input_dir / "software_config.json" + config_dict = { + "cluster_os_type": config.cluster_os_type, + "cluster_os_version": config.cluster_os_version, + "repo_config": config.repo_config, + "softwares": config.softwares, + "slurm_custom": config.slurm_custom, + "service_k8s": config.service_k8s + } + write_json(software_config_file, config_dict) + logger.info("Updated software config: %s", software_config_file) + + def get_pxe_mapping(self) -> List[PXEFunctionalGroup]: + """Get PXE functional groups from PXE mapping file. + + Returns: + List of PXEFunctionalGroup objects + """ + pxe_mapping_file = self.input_dir / "pxe_mapping_file.csv" + try: + rows = read_csv(pxe_mapping_file) + except FileNotFoundError: + logger.warning("PXE mapping not found: %s", pxe_mapping_file) + raise PXEMappingNotFoundError(str(pxe_mapping_file)) + except OSError as exc: + logger.error("Error reading PXE mapping %s: %s", pxe_mapping_file, exc) + raise PXEMappingNotFoundError(str(pxe_mapping_file)) + + if len(rows) < 2: + return [] + + # Skip header row + groups = [] + for row in rows[1:]: + row_dict = dict(zip(self._PXE_COLUMNS, row)) + group = PXEFunctionalGroup( + functional_group_name=row_dict.get("functional_group_name", ""), + group_name=row_dict.get("group_name", ""), + service_tag=row_dict.get("service_tag"), + parent_service_tag=row_dict.get("parent_service_tag"), + hostname=row_dict.get("hostname"), + admin_mac=row_dict.get("admin_mac"), + admin_ip=row_dict.get("admin_ip"), + bmc_mac=row_dict.get("bmc_mac"), + bmc_ip=row_dict.get("bmc_ip"), + ib_nic_name=row_dict.get("ib_nic_name"), + ib_ip=row_dict.get("ib_ip") + ) + groups.append(group) + + return groups + + def update_pxe_mapping(self, groups: List[PXEFunctionalGroup]) -> None: + """Update PXE mapping file. + + Args: + groups: List of PXEFunctionalGroup objects + """ + pxe_mapping_file = self.input_dir / "pxe_mapping_file.csv" + + # Header row + rows = [[ + "FUNCTIONAL_GROUP_NAME", "GROUP_NAME", "SERVICE_TAG", "PARENT_SERVICE_TAG", + "HOSTNAME", "ADMIN_MAC", "ADMIN_IP", "BMC_MAC", "BMC_IP", "IB_NIC_NAME", "IB_IP" + ]] + + # Data rows + for group in groups: + row = [ + group.functional_group_name, + group.group_name, + group.service_tag or "", + group.parent_service_tag or "", + group.hostname or "", + group.admin_mac or "", + group.admin_ip or "", + group.bmc_mac or "", + group.bmc_ip or "", + group.ib_nic_name or "", + group.ib_ip or "" + ] + rows.append(row) + + write_csv(pxe_mapping_file, rows) + logger.info("Updated PXE mapping: %s", pxe_mapping_file) + + def get_deployment_config(self, config_name: str) -> Optional[Dict[str, Any]]: + """Get deployment configuration YAML file. + + Args: + config_name: Name of config (e.g., "network_spec", "storage_config") + + Returns: + Configuration as dictionary, or None if not found + """ + self._validate_path_component(config_name, "config_name") + + config_file = self.input_dir / f"{config_name}.yml" + try: + return read_yaml(config_file) + except FileNotFoundError: + logger.warning("Deployment config not found: %s", config_file) + raise ConfigurationNotFoundError(config_name) + except OSError as exc: + logger.error("Error reading deployment config %s: %s", config_file, exc) + raise ConfigurationNotFoundError(config_name) + + def update_deployment_config(self, config_name: str, config: Dict[str, Any]) -> None: + """Update deployment configuration YAML file. + + Args: + config_name: Name of config + config: Updated configuration + """ + self._validate_path_component(config_name, "config_name") + + config_file = self.input_dir / f"{config_name}.yml" + write_yaml(config_file, config) + logger.info("Updated deployment config: %s", config_file) + + def generate_functional_layers(self) -> list: + """Generate functional layers from PXE mapping. + + Returns: + List of FunctionalLayer objects + """ + from ..models.catalog_schemas import FunctionalLayer + + pxe_groups = self.get_pxe_mapping() + + # Extract unique functional group names and create layers + layer_names = set() + for group in pxe_groups: + layer_names.add(group.functional_group_name) + + layers = [] + for layer_name in sorted(layer_names): + layer = FunctionalLayer( + name=layer_name, + functional_packages=[] # Packages will be added during catalog generation + ) + layers.append(layer) + + return layers + + def _load_preset( + self, preset_id: str, name: str, description: str, + scheduler: str, architecture: str, catalog_name: str, + ) -> Optional[Dict[str, Any]]: + """Load a single preset configuration. + + Args: + preset_id: Preset identifier + name: Preset display name + description: Preset description + scheduler: Scheduler type + architecture: Architecture type + catalog_name: Catalog file name (e.g., "catalog_rhel_x86_64") + + Returns: + Preset data dictionary or None if software config not found + """ + examples_dir = self.settings.examples_dir + input_dir = self.settings.output_dir + + catalog_path = examples_dir / "catalog" / f"{catalog_name}.json" + sw_config_dir = ( + examples_dir / "catalog" / "mapping_file_software_config" + / f"{catalog_name}_json" + ) + sw_config_path = sw_config_dir / "software_config.json" + + try: + software_config = read_json(sw_config_path) + except (OSError, PermissionError): + return None + + preset_data = { + "id": preset_id, + "name": name, + "description": description, + **software_config, + "scheduler": scheduler, + "architecture": architecture, + "catalog_file": catalog_path.name if catalog_path.exists() else None, + } + + # Load additional configuration files + config_files = [ + ("omnia_config.yml", "omnia_config"), + ("telemetry_config.yml", "telemetry_config"), + ("storage_config.yml", "storage_config"), + ("telemetry_storage_config.yml", "telemetry_storage_config"), + ("high_availability_config.yml", "high_availability_config"), + ("gitlab_config.yml", "gitlab_config"), + ("build_stream_config.yml", "build_stream_config"), + ("discovery_config.yml", "discovery_config"), + ("local_repo_config.yml", "local_repo_config"), + ("network_spec.yml", "network_spec") + ] + + for filename, key in config_files: + config_path = input_dir / filename + try: + preset_data[key] = read_yaml(config_path) + except (OSError, PermissionError): + pass # Skip missing config files + + # Load PXE mapping file using EAFP + for candidate in (sw_config_dir / "pxe_mapping_file.csv", + input_dir / "pxe_mapping_file.csv"): + try: + with open(candidate, encoding='utf-8') as f: + preset_data["pxe_mapping_file"] = f.read() + break + except OSError: + continue + + # Load functional layers from catalog using EAFP + try: + catalog_data = read_json(catalog_path) + if "Catalog" in catalog_data and "FunctionalLayer" in catalog_data["Catalog"]: + preset_data["functional_layers"] = catalog_data["Catalog"]["FunctionalLayer"] + except (OSError, PermissionError): + pass # Skip if catalog read fails + + return preset_data + + def list_presets(self) -> list: + """List available configuration presets. + + Returns: + List of preset data dictionaries + """ + presets = [] + + # Load catalog_rhel_x86_64 preset + preset = self._load_preset( + preset_id="rhel_x86_64", + name="RHEL x86_64", + description="Complete RHEL 10.0 cluster with x86_64 architecture", + scheduler="both", + architecture="x86_64", + catalog_name="catalog_rhel_x86_64", + ) + if preset: + presets.append(preset) + + # Load catalog_rhel_aarch64_with_slurm_only preset + preset = self._load_preset( + preset_id="rhel_aarch64_slurm", + name="RHEL aarch64 Slurm", + description="Complete RHEL 10.0 Slurm cluster with aarch64 architecture", + scheduler="slurm", + architecture="aarch64", + catalog_name="catalog_rhel_aarch64_with_slurm_only", + ) + if preset: + presets.append(preset) + + return presets diff --git a/utils/gui/backend/services/job_store.py b/utils/gui/backend/services/job_store.py new file mode 100644 index 0000000000..a2cbd9eb25 --- /dev/null +++ b/utils/gui/backend/services/job_store.py @@ -0,0 +1,223 @@ +"""Job store for tracking background job state. +Provides thread-safe storage for job progress tracking. +Replaces module-level _jobs dict with proper encapsulation. +""" +import threading +import time +import uuid +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class TooManyConcurrentJobsError(Exception): + """Raised when the concurrent job limit is reached.""" + + +class JobStore: + """Thread-safe store for background job state.""" + + # Fields the worker is known to send (audit via: grep -rn "update_job") + _KNOWN_FIELDS = frozenset({ + "status", "progress", "error", "result", + }) + + _VALID_TRANSITIONS = { + "pending": {"in_progress", "failed"}, + "in_progress": {"completed", "failed"}, + "completed": set(), + "failed": set(), + } + + _VALID_STATUSES = frozenset(_VALID_TRANSITIONS.keys()) + + def __init__(self, max_concurrent_jobs: int = 3): + """Initialize job store. + + Args: + max_concurrent_jobs: Maximum number of concurrent jobs allowed + + Raises: + ValueError: If max_concurrent_jobs is less than 1 + """ + if max_concurrent_jobs < 1: + raise ValueError( + f"max_concurrent_jobs must be >= 1, got {max_concurrent_jobs}" + ) + self._jobs: Dict[str, Dict[str, Any]] = {} + self._lock = threading.Lock() + self._max_concurrent_jobs = max_concurrent_jobs + + @property + def max_concurrent_jobs(self) -> int: + """Maximum number of concurrent jobs allowed.""" + return self._max_concurrent_jobs + + def __repr__(self) -> str: + # No lock — avoids deadlock when repr is called from logger/debugger + return ( + f"JobStore(jobs={len(self._jobs)}, " + f"max={self._max_concurrent_jobs})" + ) + + def create_job(self) -> str: + """Create a new job with concurrency limit. + + Returns: + Job ID string + + Raises: + TooManyConcurrentJobsError: If concurrent job limit reached + """ + with self._lock: + active = sum( + 1 for j in self._jobs.values() + if j["status"] in ("pending", "running") + ) + if active >= self._max_concurrent_jobs: + raise TooManyConcurrentJobsError( + f"Limit of {self._max_concurrent_jobs} concurrent jobs reached" + ) + job_id = str(uuid.uuid4()) + self._jobs[job_id] = { + "status": "pending", + "progress": 0, + "error": None, + "created_at": time.time(), + } + logger.debug("Created job %s", job_id) + return job_id + + def get_job(self, job_id: str) -> Optional[Dict[str, Any]]: + """Get a snapshot copy of job state. + + Args: + job_id: Job identifier + + Returns: + Job state dictionary or None if not found + """ + with self._lock: + job = self._jobs.get(job_id) + return dict(job) if job else None + + def update_job(self, job_id: str, **fields) -> None: + """Atomically update job fields. + + Warns on unexpected fields instead of crashing, so background + worker threads are never killed silently. + + Args: + job_id: Job identifier + **fields: Fields to update (status, progress, error, result) + + Raises: + KeyError: If job_id not found + """ + # --- Stateless validation (warn, don't crash) --- + unexpected = set(fields) - self._KNOWN_FIELDS + if unexpected: + logger.warning( + "Unexpected field(s) in update_job for %s: %s", + job_id, unexpected, + ) + + if "progress" in fields: + p = fields["progress"] + if isinstance(p, bool) or not isinstance(p, (int, float)): + logger.warning( + "Invalid progress type for job %s: %r", job_id, p, + ) + fields.pop("progress") + else: + fields["progress"] = max(0, min(100, int(p))) + + # --- Stateful validation + update (under lock) --- + with self._lock: + if job_id not in self._jobs: + raise KeyError(f"Job not found: {job_id!r}") + + job = self._jobs[job_id] + + if "status" in fields: + new_status = fields["status"] + allowed = self._VALID_TRANSITIONS.get(job["status"], set()) + if new_status not in allowed: + logger.warning( + "Invalid transition for job %s: %r → %r", + job_id, job["status"], new_status, + ) + fields.pop("status") + elif new_status in ("completed", "failed"): + job["finished_at"] = time.time() + + job.update(fields) + logger.debug("Updated job %s with fields: %s", job_id, set(fields.keys())) + + def delete_job(self, job_id: str) -> bool: + """Delete a job from the store. + + Args: + job_id: Job identifier + + Returns: + True if job was deleted, False if not found + + Raises: + ValueError: If job is in pending or running state + """ + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return False + if job["status"] in ("pending", "running"): + raise ValueError( + f"Cannot delete job {job_id!r} in state {job['status']!r}" + ) + del self._jobs[job_id] + logger.debug("Deleted job %s", job_id) + return True + + def list_jobs(self, status: Optional[str] = None) -> list[Dict[str, Any]]: + """Return snapshot list of all jobs, optionally filtered by status. + + Args: + status: Optional status filter + + Returns: + List of job state dictionaries with job_id included + + Raises: + ValueError: If status filter is invalid + """ + if status is not None and status not in self._VALID_STATUSES: + raise ValueError(f"Invalid status filter: {status!r}") + with self._lock: + return [ + {"job_id": jid, **dict(j)} + for jid, j in self._jobs.items() + if status is None or j["status"] == status + ] + + def cleanup(self, max_age_seconds: float = 3600) -> int: + """Remove terminal jobs older than max_age_seconds. + + Args: + max_age_seconds: Maximum age in seconds for terminal jobs + + Returns: + Number of jobs removed + """ + cutoff = time.time() - max_age_seconds + with self._lock: + stale = [ + jid for jid, j in self._jobs.items() + if j["status"] in ("completed", "failed") + and j.get("finished_at", j["created_at"]) < cutoff + ] + for jid in stale: + del self._jobs[jid] + if stale: + logger.info("Cleaned up %d stale jobs", len(stale)) + return len(stale) \ No newline at end of file diff --git a/utils/gui/backend/services/local_repo_generator_service.py b/utils/gui/backend/services/local_repo_generator_service.py new file mode 100644 index 0000000000..1b850f2ec0 --- /dev/null +++ b/utils/gui/backend/services/local_repo_generator_service.py @@ -0,0 +1,87 @@ +"""Local repository configuration generation service. + +Generates local_repo_config.yml and user_registry_credential.yml independently +from the deployment wizard. +""" + +import logging +from pathlib import Path +from typing import Dict, Any, Optional + +from ..config.settings import get_settings +from ..core.exceptions import GenerationError +from .config_file_generators import ( + generate_local_repo_config, + generate_user_registry_credential, +) + +logger = logging.getLogger(__name__) + + +class LocalRepoGeneratorService: + """Service for generating local repository configuration files.""" + + def __init__(self, settings=None): + """Initialize the service. + + Args: + settings: Optional settings instance. If None, uses default. + """ + self.settings = settings or get_settings() + logger.info("LocalRepoGeneratorService initialized with output_dir: %s", self.settings.output_dir) + + def generate_local_repo_configs( + self, + job_id: str = None, + update_job=None, + data: Dict[str, Any] = None, + output_dir: Optional[Path] = None, + ) -> Dict[str, Any]: + """Generate local_repo_config.yml and user_registry_credential.yml. + + Args: + job_id: Optional job ID for progress tracking + update_job: Optional function to update job progress + data: Local repo management data from the frontend + output_dir: Optional output directory override + + Returns: + Dictionary with generation results + """ + try: + logger.info("generate_local_repo_configs called with job_id=%s, data=%s", job_id, data is not None) + + if not data: + raise GenerationError("No local repo data provided.") + + input_dir = output_dir.expanduser().resolve() if output_dir else self.settings.output_dir + try: + input_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise GenerationError(f"Failed to create output directory {input_dir}: {str(e)}") + + if update_job and job_id: + update_job(job_id, progress=30) + + # Generate local_repo_config.yml (handles both per-OS and legacy payloads) + generate_local_repo_config(data, input_dir, None) + + if update_job and job_id: + update_job(job_id, progress=70) + + # Generate user_registry_credential.yml if credentials are enabled + generate_user_registry_credential(data, input_dir, None) + + if update_job and job_id: + update_job(job_id, progress=100) + + logger.info("Local repo configuration files generated successfully at %s", input_dir) + return { + "config_files_generated": True, + "input_dir": str(input_dir), + } + except GenerationError: + raise + except Exception as e: + logger.exception("Local repo config generation failed") + raise GenerationError("Local repo config generation failed") from e diff --git a/utils/gui/backend/services/os_package_service.py b/utils/gui/backend/services/os_package_service.py new file mode 100644 index 0000000000..eef326ad49 --- /dev/null +++ b/utils/gui/backend/services/os_package_service.py @@ -0,0 +1,520 @@ +"""Service for dynamically extracting OS packages from config files. + +This service reads the actual config JSON files from input/config/ +and extracts package definitions based on bundle membership. + +It categorizes packages as: +- Functional: service_k8s, slurm_custom, additional_packages +- Infrastructure: csi_driver_powerscale +- OS (BaseOS): default_packages, admin_debug_packages, openldap, openmpi, ucx, ldms, nfs +""" + +import json +import os +import re +import threading +from collections.abc import Iterator +from typing import Any, Dict, List, Optional +import logging + +from ..core.constants import FUNCTIONAL_BUNDLES, INFRA_BUNDLES, OS_BUNDLES + +logger = logging.getLogger(__name__) + +# Safe path component regex to prevent path traversal +_SAFE_PATH_RE = re.compile(r'^[a-zA-Z0-9._-]+$') + + +class OSPackageService: + """Service for extracting OS packages from config directory. + + This service reads the actual config JSON files from input/config/ + and extracts package definitions based on bundle membership. + + It categorizes packages as: + - Functional: service_k8s, slurm_custom, additional_packages + - Infrastructure: csi_driver_powerscale + - OS (BaseOS): default_packages, admin_debug_packages, openldap, openmpi, ucx, ldms, nfs + """ + + # Use shared bundle classification constants + _FUNCTIONAL_BUNDLES = FUNCTIONAL_BUNDLES + _INFRA_BUNDLES = INFRA_BUNDLES + _OS_BUNDLES = OS_BUNDLES + + # Version regex for extracting bundle names + _VERSION_RE = re.compile(r'^[\d]+(\.[\d]+)*$') + + # URL version extraction patterns (compiled for performance) + _URL_VERSION_PATTERNS = [ + re.compile(r'/refs/tags/v?([\d.]+)'), + re.compile(r'/[\w-]+-([\d.]+)\.tar\.gz'), + re.compile(r'version=([\d.]+)'), + re.compile(r'/(\d+\.\d+(?:[-.][\d.]+)*)/'), # requires at least major.minor + ] + + # Pre-sorted known bundles for efficient matching + _ALL_KNOWN_BUNDLES = tuple( + sorted( + _FUNCTIONAL_BUNDLES | _INFRA_BUNDLES | _OS_BUNDLES, + key=len, + reverse=True, + ) + ) + + # Fallback regex for version suffix stripping + _VERSION_SUFFIX_RE = re.compile(r'[-_]v?\d+(\.\d+)*$') + + def __init__(self, settings=None, config_dir: Optional[str] = None): + """Initialize service with settings or config directory path. + + Args: + settings: Settings instance with base_input_dir path + config_dir: Direct config directory path (for backward compatibility). + When using settings, the path is settings.base_input_dir / "config". + + Raises: + ValueError: If both settings and config_dir are provided. + """ + from ..config.settings import get_settings + + if settings is not None and config_dir is not None: + raise ValueError("Provide 'settings' or 'config_dir', not both") + + if config_dir is not None: + self.config_dir = config_dir + self.settings = None + else: + self.settings = settings or get_settings() + self.config_dir = str(self.settings.base_input_dir / "config") + + self._json_cache: Dict[str, dict] = {} + self._lock = threading.Lock() + logger.debug("OSPackageService initialized with config_dir: %s", self.config_dir) + + def __repr__(self) -> str: + return f"OSPackageService(config_dir={self.config_dir!r})" + + def list_available_combinations(self) -> List[Dict[str, str]]: + """List all available OS/arch/version combinations. + + Returns: + List of dictionaries with os_family, version, arch keys + + Example: + [ + {"os_family": "rhel", "version": "10.0", "arch": "x86_64"}, + {"os_family": "rhel", "version": "10.0", "arch": "aarch64"} + ] + """ + combinations = [] + + try: + entries = os.listdir(self.config_dir) + except FileNotFoundError: + logger.warning("Config directory does not exist: %s", self.config_dir) + return combinations + except OSError as exc: + logger.error("Error listing config directory %s: %s", self.config_dir, exc) + return combinations + + for arch in entries: + arch_path = os.path.join(self.config_dir, arch) + if not os.path.isdir(arch_path): + continue + + try: + os_families = os.listdir(arch_path) + except OSError: + continue + + for os_family in os_families: + os_family_path = os.path.join(arch_path, os_family) + if not os.path.isdir(os_family_path): + continue + + try: + versions = os.listdir(os_family_path) + except OSError: + continue + + for version in versions: + version_path = os.path.join(os_family_path, version) + # Note: using os.path.isdir here is acceptable for filesystem enumeration + # where TOCTOU risk is minimal and directory structure is trusted + if os.path.isdir(version_path): + combinations.append({ + "os_family": os_family, + "version": version, + "arch": arch + }) + + logger.info("Found %d OS combinations", len(combinations)) + return sorted(combinations, key=lambda x: (x['os_family'], x['version'], x['arch'])) + + def list_available_bundles( + self, + arch: str, + os_family: str, + version: str + ) -> List[Dict[str, Any]]: + """List all available bundles for a given OS/arch/version. + + Args: + arch: Architecture (x86_64, aarch64) + os_family: OS family (rhel, ubuntu) + version: OS version (10.0, 9.5) + + Returns: + List of bundle dictionaries with name, type, package_count + + Example: + [ + {"name": "default_packages", "type": "os", "package_count": 38}, + {"name": "admin_debug_packages", "type": "os", "package_count": 54}, + {"name": "service_k8s", "type": "functional", "package_count": 120} + ] + """ + self._validate_path_component(arch, "arch") + self._validate_path_component(os_family, "os_family") + self._validate_path_component(version, "version") + + config_path = os.path.join(self.config_dir, arch, os_family, version) + + try: + files = os.listdir(config_path) + except FileNotFoundError: + logger.warning("Config path does not exist: %s", config_path) + return [] + except OSError as exc: + logger.error("Error listing config path %s: %s", config_path, exc) + return [] + + bundles = [] + + for file in files: + if not file.endswith('.json'): + continue + + bundle_name, _ = os.path.splitext(file) + file_path = os.path.join(config_path, file) + + try: + data = self._load_json_cached(file_path) + package_count = self._count_packages(data) + + bundle_type = self._classify_bundle(bundle_name) + + bundles.append({ + "name": bundle_name, + "type": bundle_type, + "package_count": package_count, + "sections": list(data.keys()) + }) + except (json.JSONDecodeError, OSError, KeyError, TypeError) as exc: + logger.error("Error reading %s: %s", file, exc) + + return sorted(bundles, key=lambda x: x['name']) + + def get_bundle_packages( + self, + arch: str, + os_family: str, + version: str, + bundle_name: str + ) -> Dict[str, List[Dict]]: + """Get packages from a specific bundle, organized by section. + + Args: + arch: Architecture + os_family: OS family + version: OS version + bundle_name: Bundle name (e.g., "default_packages") + + Returns: + Dictionary mapping section names to package lists + + Example: + { + "default_packages": [ + {"package": "systemd", "type": "rpm", "repo_name": "baseos"}, + {"package": "kernel", "type": "rpm", "repo_name": "baseos"} + ] + } + """ + self._validate_path_component(arch, "arch") + self._validate_path_component(os_family, "os_family") + self._validate_path_component(version, "version") + self._validate_path_component(bundle_name, "bundle_name") + + config_path = os.path.join(self.config_dir, arch, os_family, version, f"{bundle_name}.json") + + try: + data = self._load_json_cached(config_path) + except FileNotFoundError: + logger.warning("Bundle file does not exist: %s", config_path) + return {} + except (json.JSONDecodeError, OSError) as exc: + logger.error("Error reading bundle %s: %s", bundle_name, exc) + return {} + + result = {} + for section_name, pkg in self._iter_packages(data): + package_data = self._build_package_data(pkg) + result.setdefault(section_name, []).append(package_data) + + return result + + def _extract_version_from_url(self, url: str) -> Optional[str]: + """Extract version from a tarball URL. + + Args: + url: The URL to extract version from + + Returns: + Extracted version string or None if not found + """ + for pattern in self._URL_VERSION_PATTERNS: + match = pattern.search(url) + if match: + return match.group(1) + + return None + + def get_os_packages( + self, + arch: str, + os_family: str, + version: str, + include_bundles: Optional[set[str]] = None + ) -> Dict[str, List[Dict]]: + """Get all OS packages (non-functional, non-infra). + + Args: + arch: Architecture + os_family: OS family + version: OS version + include_bundles: Optional set of specific bundle names to include. + If None, includes all OS bundles. + + Returns: + Dictionary mapping bundle names to package lists + + Example: + { + "default_packages": [...], + "admin_debug_packages": [...], + "openldap": [...] + } + """ + # Note: list_available_bundles and get_bundle_packages also validate, + # but we validate here too for fail-fast on direct calls. + self._validate_path_component(arch, "arch") + self._validate_path_component(os_family, "os_family") + self._validate_path_component(version, "version") + + # list_available_bundles already validates and lists the directory + all_bundles = self.list_available_bundles(arch, os_family, version) + os_bundles = [ + b for b in all_bundles + if b['type'] == 'os' and (include_bundles is None or b['name'] in include_bundles) + ] + + result = {} + for bundle in os_bundles: + bundle_packages = self.get_bundle_packages(arch, os_family, version, bundle['name']) + result.update(bundle_packages) + + return result + + def search_packages( + self, + arch: str, + os_family: str, + version: str, + query: str + ) -> List[Dict]: + """Search for packages across all bundles. + + Args: + arch: Architecture + os_family: OS family + version: OS version + query: Search term (package name substring). Empty string matches all packages. + + Returns: + List of matching packages with bundle and section info + + Example: + [ + { + "package": "systemd", + "type": "rpm", + "bundle": "default_packages", + "section": "default_packages", + "repo_name": "baseos" + } + ] + """ + self._validate_path_component(arch, "arch") + self._validate_path_component(os_family, "os_family") + self._validate_path_component(version, "version") + + config_path = os.path.join(self.config_dir, arch, os_family, version) + + try: + files = os.listdir(config_path) + except FileNotFoundError: + return [] + except OSError as exc: + logger.error("Error listing config path %s: %s", config_path, exc) + return [] + + results = [] + query_lower = query.lower() + + for file in files: + if not file.endswith('.json'): + continue + + bundle_name, _ = os.path.splitext(file) + file_path = os.path.join(config_path, file) + + try: + data = self._load_json_cached(file_path) + + for section_name, pkg in self._iter_packages(data): + if query_lower in pkg['package'].lower(): + result_item = self._build_package_data(pkg) + result_item["bundle"] = bundle_name + result_item["section"] = section_name + results.append(result_item) + except (json.JSONDecodeError, OSError, KeyError, TypeError) as exc: + logger.error("Error searching in %s: %s", file, exc) + + return sorted(results, key=lambda x: x['package']) + + def reload(self) -> None: + """Force re-read from disk on the next access. + + Thread-safe: concurrent readers will see either the old + cached value or wait for a fresh load after invalidation. + """ + with self._lock: + self._json_cache.clear() + + # Private helper methods + + def _validate_path_component(self, value: str, name: str) -> None: + """Reject path components containing traversal characters.""" + if not _SAFE_PATH_RE.match(value): + raise ValueError(f"Invalid {name}: {value!r}") + + def _load_json_cached(self, filepath: str) -> dict: + """Load and cache JSON file with thread-safety. + + Callers MUST NOT mutate the returned dict — it is a cached reference. + Uses double-checked locking for thread-safe lazy loading. + """ + cache = self._json_cache + if filepath in cache: + return cache[filepath] + + with self._lock: + if filepath in self._json_cache: + return self._json_cache[filepath] + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + self._json_cache[filepath] = data + return data + + def _count_packages(self, data: dict) -> int: + """Count total packages in bundle data.""" + return sum( + len(section_data['cluster']) + for section_data in data.values() + if isinstance(section_data, dict) and 'cluster' in section_data + ) + + def _build_package_data(self, pkg: dict) -> dict: + """Build a normalized package dict from raw config entry. + + Omits None values and extracts tarball versions from URLs when needed. + """ + package_data = { + "package": pkg["package"], + "type": pkg["type"], + } + for key in ("repo_name", "url", "tag", "version"): + if pkg.get(key) is not None: + package_data[key] = pkg[key] + if ( + pkg.get("type") == "tarball" + and pkg.get("url") + and not package_data.get("version") + ): + extracted = self._extract_version_from_url(pkg["url"]) + if extracted: + package_data["version"] = extracted + return package_data + + def _iter_packages(self, data: dict) -> Iterator[tuple[str, dict]]: + """Yield (section_name, pkg) pairs from loaded bundle data. + + Skips entries missing required 'package' or 'type' keys. + """ + for section_name, section_data in data.items(): + if not isinstance(section_data, dict) or "cluster" not in section_data: + continue + for pkg in section_data["cluster"]: + if "package" not in pkg or "type" not in pkg: + logger.warning("Missing required key(s) in package: %s", pkg) + continue + yield section_name, pkg + + def _extract_bundle_name(self, filename_stem: str) -> str: + """Strip version suffix from a config filename stem. + + This uses the same logic as generate_catalog.py for consistency. + + Examples: + service_k8s_v1.35.1 -> service_k8s + service_k8s_1.35.1 -> service_k8s + service_k8s-1.35.1 -> service_k8s + slurm_custom -> slurm_custom + """ + # Try matching a known bundle prefix (pre-sorted for efficiency) + for name in self._ALL_KNOWN_BUNDLES: + if filename_stem == name: + return name + # version suffixed with _v, _, or - + if filename_stem.startswith(name) and len(filename_stem) > len(name): + sep = filename_stem[len(name)] + if sep in ('_', '-'): + remainder = filename_stem[len(name) + 1:] + # strip optional leading 'v' + if remainder.startswith('v'): + remainder = remainder[1:] + if remainder and self._VERSION_RE.match(remainder): + return name + # Fallback: try generic regex stripping + stripped = self._VERSION_SUFFIX_RE.sub('', filename_stem) + return stripped + + def _classify_bundle(self, bundle_name: str) -> str: + """Classify bundle as functional, infra, or os. + + Uses _extract_bundle_name to handle version suffixes. + """ + # Extract base bundle name (strip version suffix) + base_name = self._extract_bundle_name(bundle_name) + + if base_name in self._FUNCTIONAL_BUNDLES: + return "functional" + elif base_name in self._INFRA_BUNDLES: + return "infrastructure" + elif base_name in self._OS_BUNDLES: + return "os" + else: + logger.debug("Unknown bundle %r classified as 'os'", bundle_name) + return "os" diff --git a/utils/gui/backend/services/software_config_service.py b/utils/gui/backend/services/software_config_service.py new file mode 100644 index 0000000000..16571b3b55 --- /dev/null +++ b/utils/gui/backend/services/software_config_service.py @@ -0,0 +1,222 @@ +"""Service for reading software_config.json and extracting role/bundle mappings. + +This service loads the software_config.json file and provides methods to: +- List all available roles +- Get bundles associated with a role +- Get allowed bundles per architecture +- Get software versions +""" + +import copy +import json +import os +import threading +from collections.abc import Iterator +from typing import Any, Optional + +import logging + +logger = logging.getLogger(__name__) + +# Top-level keys that are not bundle-to-roles mappings. +_NON_BUNDLE_KEYS = frozenset({"bundle_roles", "allowed_bundles", "software_versions"}) + + +class SoftwareConfigService: + """Service for reading software_config.json and extracting role/bundle mappings.""" + + def __init__(self, settings=None, config_dir: Optional[str] = None): + """Initialize service with settings or config directory path. + + Args: + settings: Settings instance with base_input_dir path + config_dir: Direct config directory path (for backward compatibility) + + Raises: + ValueError: If both settings and config_dir are provided. + """ + from ..config.settings import get_settings + + if settings is not None and config_dir is not None: + raise ValueError("Provide 'settings' or 'config_dir', not both") + + if config_dir is not None: + self.config_dir = config_dir + self.settings = None + else: + self.settings = settings or get_settings() + self.config_dir = self.settings.base_input_dir + + self.software_config_path = os.path.join(self.config_dir, 'software_config.json') + self._config_cache: Optional[dict[str, Any]] = None + self._lock = threading.Lock() + logger.debug("SoftwareConfigService initialized with config_dir: %s", self.config_dir) + + def __repr__(self) -> str: + return f"SoftwareConfigService(config_dir={self.config_dir!r})" + + def reload(self) -> None: + """Force re-read from disk on the next access. + + Thread-safe: concurrent readers will see either the old + cached value or wait for a fresh load after invalidation. + """ + with self._lock: + self._config_cache = None + + # ------------------------------------------------------------------ # + # Internal loader # + # ------------------------------------------------------------------ # + + def _load_software_config(self) -> dict[str, Any]: + """Load and cache software_config.json (EAFP, no TOCTOU). + + Returns cached config. Internal use only — callers MUST NOT mutate. + """ + cache = self._config_cache + if cache is not None: + return cache + + with self._lock: + cache = self._config_cache + if cache is not None: + return cache + + try: + with open(self.software_config_path, 'r', encoding='utf-8') as f: + data = json.load(f) + except FileNotFoundError: + logger.warning("Config not found: %s", self.software_config_path) + data = {} + except json.JSONDecodeError as exc: + logger.error("Bad JSON in %s: %s", self.software_config_path, exc) + data = {} + except OSError as exc: + logger.error("IO error reading %s: %s", self.software_config_path, exc) + data = {} + else: + if not isinstance(data, dict): + logger.error( + "Expected JSON object in %s, got %s", + self.software_config_path, + type(data).__name__, + ) + data = {} + + self._config_cache = data + return data + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + def get_all_roles(self) -> set[str]: + """Return all role names defined under ``bundle_roles``.""" + config = self._load_software_config() + return set(config.get("bundle_roles", {}).keys()) + + def get_bundles_for_role(self, role: str) -> list[str]: + """Return bundles associated with a given role (new format only). + + Args: + role: The role name to look up. + + Returns: + List of bundle names, or empty list if role not found. + """ + config = self._load_software_config() + return list(config.get("bundle_roles", {}).get(role, [])) + + def get_role_bundles(self, role: str) -> list[str]: + """Get bundles associated with a specific role (legacy + new format). + + Args: + role: Role name (e.g., 'slurm_control_node') + + Returns: + List of bundle names associated with the role + """ + config = self._load_software_config() + bundles: set[str] = set() + + # New format: bundle_roles mapping + if "bundle_roles" in config: + bundles.update(config["bundle_roles"].get(role, [])) + + # Legacy format: top-level key is a bundle named after the role + if role in config and isinstance(config[role], list): + bundles.add(role) + + # Legacy format: role appears in bundle's role list + for key, value in config.items(): + if key in _NON_BUNDLE_KEYS: + continue + if isinstance(value, list): + for r in value: + if isinstance(r, str) and r == role: + bundles.add(key) + elif isinstance(r, dict) and r.get("name") == role: + bundles.add(key) + + return sorted(list(bundles)) + + def get_allowed_bundles( + self, architecture: Optional[str] = None, + ) -> dict[str, list[str]]: + """Return allowed bundles, optionally filtered by architecture. + + Args: + architecture: If provided, return only bundles for this arch. + + Returns: + Dict mapping architecture names to lists of bundle names. + """ + config = self._load_software_config() + allowed = config.get("allowed_bundles", {}) + if architecture is not None: + return {architecture: list(allowed.get(architecture, []))} + return {k: list(v) for k, v in allowed.items()} + + def get_software_versions(self) -> dict[str, str]: + """Return software version mappings.""" + config = self._load_software_config() + return dict(config.get("software_versions", {})) + + def get_bundle_metadata(self, bundle_name: str) -> dict[str, Any]: + """Return version and architecture metadata for a software bundle. + + Args: + bundle_name: The bundle name to look up. + + Returns: + Dict with 'version' and 'arch' keys (empty if not found). + """ + config = self._load_software_config() + if 'softwares' in config: + for software in config['softwares']: + if software.get('name') == bundle_name: + return { + 'version': software.get('version', ''), + 'arch': software.get('arch', []) + } + return {'version': '', 'arch': []} + + def get_bundle_mappings(self) -> dict[str, Any]: + """Return top-level entries that are bundle-to-role mappings. + + Excludes known non-bundle keys: ``bundle_roles``, + ``allowed_bundles``, ``software_versions``. + """ + config = self._load_software_config() + return { + k: copy.deepcopy(v) + for k, v in config.items() + if k not in _NON_BUNDLE_KEYS + } + + def iter_role_bundle_pairs(self) -> Iterator[tuple[str, str]]: + """Yield ``(role, bundle)`` pairs from ``bundle_roles``.""" + config = self._load_software_config() + for role, bundles in config.get("bundle_roles", {}).items(): + for bundle in bundles: + yield role, bundle \ No newline at end of file diff --git a/utils/gui/backend/services/telemetry_helpers.py b/utils/gui/backend/services/telemetry_helpers.py new file mode 100644 index 0000000000..c271576233 --- /dev/null +++ b/utils/gui/backend/services/telemetry_helpers.py @@ -0,0 +1,121 @@ +"""Helper functions for telemetry sink/source logic. +Routing topology: + +Each telemetry source has ``metrics_enabled`` / ``logs_enabled`` boolean +flags and a ``collection_targets`` list (e.g. ``["kafka"]``, +``["victoria_metrics"]``, ``["victoria_logs"]``). + +Direct paths + source (metrics_enabled=True) -> victoria_metrics + source (logs_enabled=True) -> victoria_logs + +Kafka bridge paths + LDMS (metrics) -> Kafka -> Vector-LDMS -> victoria_metrics + OME (metrics) -> Kafka -> Vector-OME -> victoria_metrics + OME (logs) -> Kafka -> Vector-OME -> victoria_logs + +LDMS does not produce logs, so no LDMS bridge path exists for logs. +""" + +from typing import Any, Dict, FrozenSet, Iterator, List, Tuple + + +# Current assumption: all Kafka bridge paths terminate at a Victoria sink. +# If Kafka is used for non-Victoria consumers in the future, this logic +# will need an explicit bridge-destination config field. + +_TARGET_KAFKA = "kafka" +_TARGET_VICTORIA_METRICS = "victoria_metrics" +_TARGET_VICTORIA_LOGS = "victoria_logs" +_SINK_VECTOR = "vector" + +# Sources that route to Victoria* through Vector when Kafka is a target. +_METRICS_BRIDGE_SOURCES = frozenset({"ldms", "ome"}) +_LOGS_BRIDGE_SOURCES = frozenset({"ome"}) + + +def _active_sources( + telemetry_sources: Dict[str, Any], capability: str = "metrics_enabled" +) -> Iterator[Tuple[str, Dict[str, Any], List[str]]]: + """Yield (name, config, targets) for sources with ``capability`` enabled. + + ``collection_targets`` is defensively normalized to a list; non-list + values are ignored to avoid substring false-positives such as + ``"kafka" in "kafka_cluster"``. + """ + if not isinstance(telemetry_sources, dict): + return + for name, cfg in telemetry_sources.items(): + if not isinstance(cfg, dict): + continue + if not cfg.get(capability, False): + continue + collection_targets = cfg.get("collection_targets", []) + if not isinstance(collection_targets, list): + continue + yield name, cfg, collection_targets + + +def required_sinks(telemetry_sources: Dict[str, Any]) -> FrozenSet[str]: + """Return the set of infrastructure sinks required by the telemetry sources. + + The returned strings are: + - "victoria_metrics" + - "victoria_logs" + - "kafka" + - "vector" + """ + sinks = set() + + for name, _, targets in _active_sources(telemetry_sources, "metrics_enabled"): + if _TARGET_VICTORIA_METRICS in targets: + sinks.add(_TARGET_VICTORIA_METRICS) + if _TARGET_KAFKA in targets: + sinks.add(_TARGET_KAFKA) + if name in _METRICS_BRIDGE_SOURCES: + sinks.update({_TARGET_VICTORIA_METRICS, _SINK_VECTOR}) + + for name, _, targets in _active_sources(telemetry_sources, "logs_enabled"): + if _TARGET_VICTORIA_LOGS in targets: + sinks.add(_TARGET_VICTORIA_LOGS) + if _TARGET_KAFKA in targets: + sinks.add(_TARGET_KAFKA) + if name in _LOGS_BRIDGE_SOURCES: + sinks.update({_TARGET_VICTORIA_LOGS, _SINK_VECTOR}) + + return frozenset(sinks) + + +def needs_victoria_metrics(telemetry_sources: Dict[str, Any]) -> bool: + """Check if victoria_metrics sink is needed based on collection targets. + + Only ``metrics_enabled`` sources are considered. + """ + return _TARGET_VICTORIA_METRICS in required_sinks(telemetry_sources) + + +def needs_victoria_logs(telemetry_sources: Dict[str, Any]) -> bool: + """Check if victoria_logs sink is needed based on collection targets. + + Only ``logs_enabled`` sources are considered. + """ + return _TARGET_VICTORIA_LOGS in required_sinks(telemetry_sources) + + +def needs_kafka(telemetry_sources: Dict[str, Any]) -> bool: + """Check if Kafka sink is needed based on collection targets. + + A source is considered active when either ``metrics_enabled`` or + ``logs_enabled`` is True; Kafka is needed if any active source lists + ``"kafka"`` in its targets. + """ + return _TARGET_KAFKA in required_sinks(telemetry_sources) + + +def needs_vector(telemetry_sources: Dict[str, Any]) -> bool: + """Check if Vector is needed for LDMS or OME bridges via Kafka. + + Vector is needed when LDMS metrics or OME metrics/logs are routed to + Kafka, which is then bridged to the Victoria* sinks. + """ + return _SINK_VECTOR in required_sinks(telemetry_sources) diff --git a/utils/gui/backend/services/wizard_generator_service.py b/utils/gui/backend/services/wizard_generator_service.py new file mode 100644 index 0000000000..42922455b2 --- /dev/null +++ b/utils/gui/backend/services/wizard_generator_service.py @@ -0,0 +1,212 @@ +"""Wizard configuration generation service. + +Orchestrates the generation of all deployment configuration files from wizard data. +""" + +import logging +from pathlib import Path +from typing import Dict, Any, Optional, Callable + +from ..config.settings import get_settings +from ..core.exceptions import GenerationError +from ..utils.file_io import write_yaml, ensure_directory +from .config_file_generators import ( + generate_omnia_config, + generate_network_spec, + generate_gitlab_config, + generate_build_stream_config, + generate_discovery_config, + generate_high_availability_config, + generate_telemetry_config, + generate_telemetry_storage_config, + generate_user_registry_credential, + generate_pxe_mapping_file, + generate_provision_config, + generate_storage_config, + generate_additional_cloud_init, + generate_security_config +) + +logger = logging.getLogger(__name__) + +GENERATED_CONFIG_FILENAMES = [ + "pxe_mapping_file.csv", + "provision_config.yml", + "omnia_config.yml", + "network_spec.yml", + "gitlab_config.yml", + "build_stream_config.yml", + "discovery_config.yml", + "high_availability_config.yml", + "telemetry_config.yml", + "telemetry_storage_config.yml", + "user_registry_credential.yml", + "storage_config.yml", + "additional_cloud_init.yml", + "security_config.yml" +] + + +class WizardGeneratorService: + """Service for generating deployment configuration files from wizard data.""" + + def __init__(self, settings=None): + """Initialize the service. + + Args: + settings: Optional settings instance. If None, uses default. + """ + self.settings = settings or get_settings() + + logger.info("WizardGeneratorService initialized with output_dir: %s", self.settings.output_dir) + + def __repr__(self) -> str: + return f"WizardGeneratorService(output_dir={self.settings.output_dir!r})" + + def generate_all_configs(self, job_id: str = None, update_job: Optional[Callable] = None, wizard_data: Dict[str, Any] = None, output_dir: Optional[Path] = None) -> Dict[str, Any]: + """Generate deployment configuration files from wizard data. + + Note: Catalog is now generated separately from catalog management. + This function only generates the deployment YAML configuration files. + + Args: + job_id: Optional job ID for progress tracking + update_job: Optional function to update job progress + wizard_data: Optional wizard data from frontend + output_dir: Optional output directory override + + Returns: + Dictionary with generation results + """ + try: + logger.info( + "generate_all_configs called with job_id=%s, update_job=%s, wizard_data=%s", + job_id, + update_job is not None, + wizard_data is not None, + ) + if job_id: + logger.info("Starting generation for job %s", job_id) + + # Validate wizard data before generation + if not wizard_data: + raise GenerationError("No wizard data provided. Please complete the configuration wizard first.") + + # Check if wizard data has any meaningful content + has_any_config_data = any([ + wizard_data.get("pxe_mapping_data"), + wizard_data.get("dns_enabled"), + wizard_data.get("default_lease_time"), + wizard_data.get("language"), + wizard_data.get("kernel_version_override"), + wizard_data.get("additional_cloud_init_config_file"), + wizard_data.get("mounts"), + wizard_data.get("cloud_init_common"), + wizard_data.get("slurm_cluster"), + wizard_data.get("service_k8s_cluster"), + wizard_data.get("service_k8s_cluster_ha"), + wizard_data.get("enable_build_stream"), + wizard_data.get("enable_bmc_discovery"), + wizard_data.get("gitlab_host"), + wizard_data.get("telemetry_sources"), + wizard_data.get("telemetry_sinks"), + wizard_data.get("user_registry_name"), + wizard_data.get("user_registry"), + wizard_data.get("user_repo_url_x86_64"), + wizard_data.get("user_repo_url_aarch64"), + wizard_data.get("user_registry_username"), + wizard_data.get("Networks"), + ]) + + if not has_any_config_data: + raise GenerationError("No configuration data provided. Please fill in at least one configuration field before generating.") + + # Determine and create the output directory + input_dir = output_dir.expanduser().resolve() if output_dir else self.settings.output_dir + try: + input_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise GenerationError(f"Failed to create output directory {input_dir}: {str(e)}") + + # Generate input files from wizard data if provided + if wizard_data: + logger.info("Generating deployment configuration files from wizard data") + if update_job and job_id: + update_job(job_id, progress=20) + + # Optional list of filenames to generate (e.g. BMC flow only needs a subset) + files_to_generate = wizard_data.pop("files_to_generate", None) if isinstance(wizard_data, dict) else None + + self._generate_input_files_from_wizard(wizard_data, job_id, update_job, input_dir, files_to_generate) + + logger.info("Deployment configuration files generation completed") + + return { + "config_files_generated": True, + "input_dir": str(input_dir) + } + except GenerationError: + raise + except Exception as e: + logger.exception("Configuration generation failed") + raise GenerationError("Configuration generation failed") from e + + def _generate_input_files_from_wizard(self, wizard_data: Dict[str, Any], job_id: str = None, update_job: Optional[Callable] = None, input_dir: Path = None, files_to_generate: Optional[list] = None): + """Generate input files from wizard data. + + Args: + files_to_generate: Optional list of filenames to generate. When omitted, + all configured generators are run. + """ + logger.info("Generating input files from wizard data") + + # Ensure output directory exists + input_dir.mkdir(parents=True, exist_ok=True) + + generator_specs = [ + ("pxe_mapping_file.csv", generate_pxe_mapping_file, (wizard_data, input_dir, ensure_directory)), + ("provision_config.yml", generate_provision_config, (wizard_data, input_dir, write_yaml)), + ("storage_config.yml", generate_storage_config, (wizard_data, input_dir, write_yaml)), + ("additional_cloud_init.yml", generate_additional_cloud_init, (wizard_data, input_dir, write_yaml)), + ("security_config.yml", generate_security_config, (wizard_data, input_dir, write_yaml)), + ("omnia_config.yml", generate_omnia_config, (wizard_data, input_dir, write_yaml)), + ("network_spec.yml", generate_network_spec, (wizard_data, input_dir, write_yaml)), + ("gitlab_config.yml", generate_gitlab_config, (wizard_data, input_dir, write_yaml)), + ("build_stream_config.yml", generate_build_stream_config, (wizard_data, input_dir, write_yaml)), + ("discovery_config.yml", generate_discovery_config, (wizard_data, input_dir, write_yaml)), + ("high_availability_config.yml", generate_high_availability_config, (wizard_data, input_dir, write_yaml)), + ("telemetry_config.yml", generate_telemetry_config, (wizard_data, input_dir, write_yaml)), + ("telemetry_storage_config.yml", generate_telemetry_storage_config, (wizard_data, input_dir, write_yaml)), + ("user_registry_credential.yml", generate_user_registry_credential, (wizard_data, input_dir, write_yaml)), + ] + + selected_specs = [ + spec for spec in generator_specs + if files_to_generate is None or spec[0] in files_to_generate + ] + + # Clean up old generated files before generating new ones + # This prevents stale files from previous generations from being included + files_to_clean = files_to_generate if files_to_generate is not None else GENERATED_CONFIG_FILENAMES + for filename in files_to_clean: + file_path = input_dir / filename + if file_path.exists(): + file_path.unlink() + logger.info("Deleted old file: %s", filename) + + if not selected_specs: + logger.info("No generators selected for the requested files") + return + + progress = 20 + increment = 80 // len(selected_specs) + for filename, gen, args in selected_specs: + gen(*args) + progress = min(100, progress + increment) + if update_job and job_id: + update_job(job_id, progress=progress) + + if update_job and job_id: + update_job(job_id, progress=100) + + logger.info("Input files generated from wizard data") diff --git a/utils/gui/backend/utils/__init__.py b/utils/gui/backend/utils/__init__.py new file mode 100644 index 0000000000..89d61997b7 --- /dev/null +++ b/utils/gui/backend/utils/__init__.py @@ -0,0 +1,14 @@ +"""Utils module.""" + +from .file_io import * + +__all__ = [ + "read_json", + "write_json", + "read_yaml", + "write_yaml", + "read_csv", + "write_csv", + "file_exists", + "ensure_directory" +] diff --git a/utils/gui/backend/utils/file_io.py b/utils/gui/backend/utils/file_io.py new file mode 100644 index 0000000000..d744e185be --- /dev/null +++ b/utils/gui/backend/utils/file_io.py @@ -0,0 +1,250 @@ +""" +File I/O operations for Config Editor Module + +Provides synchronous file read/write operations for JSON and YAML files. +Uses existing patterns from core modules where possible. +""" + +import json +import yaml +import logging +import shutil +from pathlib import Path +from datetime import datetime +from typing import Any, Dict, List, Union +from ..core.exceptions import ConfigEditorException + +logger = logging.getLogger(__name__) + + +class IndentedListDumper(yaml.Dumper): + """Custom YAML dumper that indents list items under their parent key.""" + def increase_indent(self, flow=False, indentless=False): + return super().increase_indent(flow, False) # Force indentless=False + + +class NetworkSpecDumper(yaml.Dumper): + """Custom YAML dumper for network_spec.yml with no list indentation under top-level keys.""" + def increase_indent(self, flow=False, indentless=False): + # For network_spec.yml, use indentless=True for top-level lists + # This matches the reference file format where list dashes are at same level as parent key + return super().increase_indent(flow, True) + + +class QuotedStringDumper(yaml.Dumper): + """Custom YAML dumper that quotes all string values to match reference file format.""" + def represent_mapping(self, tag, mapping, flow_style=None): + """Override to track when we're representing keys vs values.""" + # Track that we're in a mapping context + self.in_mapping_key = True + node = super().represent_mapping(tag, mapping, flow_style) + self.in_mapping_key = False + return node + + +def quoted_str_representer(dumper, data): + """Represent all strings as quoted scalars for values only (not keys).""" + # Check if this is being called for a key by examining the dumper state + if hasattr(dumper, 'in_mapping_key') and dumper.in_mapping_key: + # This is a key, use default representation (no quotes unless needed) + return dumper.represent_scalar('tag:yaml.org,2002:str', data) + # This is a value, quote it + if '\n' in data: + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') + + +# Register the custom representer for str type +QuotedStringDumper.add_representer(str, quoted_str_representer) + + +def read_json(path: Union[str, Path]) -> Dict[str, Any]: + """Read JSON file and return parsed dictionary. + + Args: + path: Path to JSON file + + Returns: + Parsed JSON as dictionary + + Raises: + FileNotFoundError: If file doesn't exist + json.JSONDecodeError: If file contains invalid JSON + ConfigEditorException: For other I/O errors + """ + try: + with open(path, 'r') as f: + return json.load(f) + except FileNotFoundError: + raise + except json.JSONDecodeError as e: + raise + except Exception as e: + raise ConfigEditorException(f"Failed to read JSON file {path}: {str(e)}") + + +def write_json(path: Union[str, Path], data: Dict[str, Any], indent: int = 2) -> None: + """Write dictionary to JSON file. + + Args: + path: Path to JSON file + data: Dictionary to write + indent: JSON indentation level (default: 2) + + Raises: + ConfigEditorException: If file cannot be written + """ + try: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w') as f: + json.dump(data, f, indent=indent) + except Exception as e: + raise ConfigEditorException(f"Failed to write JSON file {path}: {str(e)}") + + +def write_json_atomic(path: Union[str, Path], data: Dict[str, Any], indent: int = 2) -> None: + """Write JSON atomically with timestamped backup. + + Args: + path: Path to JSON file + data: Dictionary to write + indent: JSON indentation level (default: 2) + + Raises: + ConfigEditorException: If file cannot be written + """ + try: + path = Path(path) + + # Create backup if file exists + if path.exists(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = path.parent / f"{path.stem}_{timestamp}.json" + shutil.copy2(path, backup_path) + logger.info(f"Created backup at {backup_path}") + + # Write new data + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w') as f: + json.dump(data, f, indent=indent) + except Exception as e: + raise ConfigEditorException(f"Failed to write JSON file {path}: {str(e)}") + + +def read_yaml(path: Union[str, Path]) -> Dict[str, Any]: + """Read YAML file and return parsed dictionary. + + Args: + path: Path to YAML file + + Returns: + Parsed YAML as dictionary + + Raises: + FileNotFoundError: If file doesn't exist + yaml.YAMLError: If file contains invalid YAML + ConfigEditorException: For other I/O errors + """ + try: + with open(path, 'r') as f: + return yaml.safe_load(f) + except FileNotFoundError: + raise + except yaml.YAMLError as e: + raise + except Exception as e: + raise ConfigEditorException(f"Failed to read YAML file {path}: {str(e)}") + + +def write_yaml(path: Union[str, Path], data: Dict[str, Any]) -> None: + """Write dictionary to YAML file. + + Args: + path: Path to YAML file + data: Dictionary to write + + Raises: + ConfigEditorException: If file cannot be written + """ + try: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w') as f: + yaml.dump( + data, + f, + Dumper=IndentedListDumper, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + indent=2 + ) + except Exception as e: + raise ConfigEditorException(f"Failed to write YAML file {path}: {str(e)}") + + +def read_csv(path: Union[str, Path]) -> List[List[str]]: + """Read CSV file and return list of rows. + + Args: + path: Path to CSV file + + Returns: + List of CSV rows (each row is a list of strings) + + Raises: + FileNotFoundError: If file doesn't exist + ConfigEditorException: For other I/O errors + """ + try: + import csv + with open(path, 'r') as f: + reader = csv.reader(f) + return list(reader) + except FileNotFoundError: + raise + except Exception as e: + raise ConfigEditorException(f"Failed to read CSV file {path}: {str(e)}") + + +def write_csv(path: Union[str, Path], rows: List[List[str]]) -> None: + """Write list of rows to CSV file. + + Args: + path: Path to CSV file + rows: List of CSV rows (each row is a list of strings) + + Raises: + ConfigEditorException: If file cannot be written + """ + try: + import csv + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerows(rows) + except Exception as e: + raise ConfigEditorException(f"Failed to write CSV file {path}: {str(e)}") + + +def file_exists(path: Union[str, Path]) -> bool: + """Check if file exists. + + Args: + path: Path to check + + Returns: + True if file exists, False otherwise + """ + return Path(path).exists() + + +def ensure_directory(path: Union[str, Path]) -> None: + """Ensure directory exists, create if it doesn't. + + Args: + path: Directory path to ensure exists + """ + Path(path).mkdir(parents=True, exist_ok=True) diff --git a/utils/gui/frontend/.gitignore b/utils/gui/frontend/.gitignore new file mode 100644 index 0000000000..1a1516ba41 --- /dev/null +++ b/utils/gui/frontend/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +/.pnp +.pnp.js + +# Testing +/coverage + +# Production +/build + +# Vite +.vite/ + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +Thumbs.db diff --git a/utils/gui/frontend/index.html b/utils/gui/frontend/index.html new file mode 100644 index 0000000000..4ed50a7aab --- /dev/null +++ b/utils/gui/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + OMNIA Catalog Configuration + + + +
+ + + diff --git a/utils/gui/frontend/package-lock.json b/utils/gui/frontend/package-lock.json new file mode 100644 index 0000000000..f036012730 --- /dev/null +++ b/utils/gui/frontend/package-lock.json @@ -0,0 +1,2360 @@ +{ + "name": "omnia-catalog-gui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omnia-catalog-gui", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@tanstack/react-query": "^5.101.0", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^5.2.0", + "mermaid": "^11.16.0", + "papaparse": "^5.5.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.78.0", + "react-router-dom": "^6.30.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/papaparse": "^5.5.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^4.9.5", + "vite": "^8.0.16" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.0.tgz", + "integrity": "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/package-manager-detector": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", + "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", + "license": "MIT" + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.78.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.78.0.tgz", + "integrity": "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/utils/gui/frontend/package.json b/utils/gui/frontend/package.json new file mode 100644 index 0000000000..63370803ff --- /dev/null +++ b/utils/gui/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "omnia-catalog-gui", + "version": "0.1.0", + "private": true, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@tanstack/react-query": "^5.101.0", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^5.2.0", + "mermaid": "^11.16.0", + "papaparse": "^5.5.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.78.0", + "react-router-dom": "^6.30.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/papaparse": "^5.5.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^4.9.5", + "vite": "^8.0.16" + }, + "scripts": { + "start": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + } +} diff --git a/utils/gui/frontend/public/index.html b/utils/gui/frontend/public/index.html new file mode 100644 index 0000000000..d23fe6fa55 --- /dev/null +++ b/utils/gui/frontend/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + OMNIA Catalog Configuration + + + +
+ + diff --git a/utils/gui/frontend/src/App.tsx b/utils/gui/frontend/src/App.tsx new file mode 100644 index 0000000000..b21b04028f --- /dev/null +++ b/utils/gui/frontend/src/App.tsx @@ -0,0 +1,49 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ErrorBoundary from './components/ErrorBoundary'; +import { NotFound } from './components/NotFound'; +import Landing from './features/landing/Landing'; +import PresetPicker from './features/preset-picker/PresetPicker'; +import Overview from './features/overview/Overview'; +import ConfigurationWizard from './features/configuration-wizard/ConfigurationWizard'; +import { BmcDiscoveryFlow } from './features/configuration-wizard/BmcDiscoveryFlow'; +import CatalogViewer from './features/catalog/CatalogViewer'; +import { AdapterPolicyEditor } from './features/adapter-policy/AdapterPolicyEditor'; +import CatalogEditor from './features/catalog-editor/CatalogEditor'; +import LocalRepoManagement from './features/local-repo-management/LocalRepoManagement'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + }, +}); + +function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} + +export default App; diff --git a/utils/gui/frontend/src/assets/omnia-logo.png b/utils/gui/frontend/src/assets/omnia-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f2f2bf6692020a1570d1159363b860bf6efde28d GIT binary patch literal 33093 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 void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; +} + +const Button = ({ + children, + variant = 'secondary', + size = 'md', + className = '', + style = {}, + onClick, + type = 'button', + disabled = false, +}: ButtonProps) => { + const baseClasses = 'button'; + const variantClasses = `button-${variant}`; + const sizeClasses = size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : ''; + const combinedClassName = `${baseClasses} ${variantClasses} ${sizeClasses} ${className} ${disabled ? 'disabled' : ''}`.trim(); + + return ( + + ); +}; + +export default Button; diff --git a/utils/gui/frontend/src/components/ErrorBoundary.tsx b/utils/gui/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..55eeb6b8cc --- /dev/null +++ b/utils/gui/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,71 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; + errorInfo?: ErrorInfo; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ error, errorInfo }); + console.error('Error caught by ErrorBoundary:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ + {process.env.NODE_ENV === 'development' && this.state.errorInfo && ( +
+ Error Details +
+                {this.state.error?.toString()}
+                {this.state.errorInfo.componentStack}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/utils/gui/frontend/src/components/Layout.tsx b/utils/gui/frontend/src/components/Layout.tsx new file mode 100644 index 0000000000..748e0dfd5d --- /dev/null +++ b/utils/gui/frontend/src/components/Layout.tsx @@ -0,0 +1,244 @@ +import { ReactNode, useEffect } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useConfigStore } from '../features/configuration-wizard/configStore'; +import { WIZARD_STEPS } from '../features/configuration-wizard/constants'; +import ToastContainer from '../features/toast/ToastContainer'; +import ConfirmDialog from '../features/confirmDialog/ConfirmDialog'; +import omniaLogo from '../assets/omnia-logo.png'; + +interface LayoutProps { + children: ReactNode; +} + +const Layout = ({ children }: LayoutProps) => { + const location = useLocation(); + const navigate = useNavigate(); + const { activeStep, setActiveStep, wizardExpanded, setWizardExpanded, catalogExpanded, setCatalogExpanded, buildConfigExpanded, setBuildConfigExpanded, localRepoExpanded, setLocalRepoExpanded, isStepEnabled } = useConfigStore(); + + // Close wizard expanded state when navigating away from /wizard + useEffect(() => { + if (location.pathname !== '/wizard' && wizardExpanded) { + setWizardExpanded(false); + } + }, [location.pathname, wizardExpanded, setWizardExpanded]); + + // Expand/collapse local repo group based on route changes only + useEffect(() => { + const localRepoPages = ['/local-repo', '/local-repo/rhel', '/local-repo/ubuntu']; + if (localRepoPages.some((path) => location.pathname.startsWith(path))) { + setBuildConfigExpanded(true); + setLocalRepoExpanded(true); + } else { + setLocalRepoExpanded(false); + } + }, [location.pathname, setBuildConfigExpanded, setLocalRepoExpanded]); + + const isActive = (path: string) => location.pathname === path; + + const handleStepClick = (stepId: number) => { + setActiveStep(stepId); + }; + + const handleNavigation = (path: string) => { + navigate(path); + }; + + return ( +
+ {/* Top Bar */} +
+ + Omnia + +
+ + {/* Main Content Area with Sidebar */} +
+ {/* Left Sidebar */} + + + {/* Main Content */} +
+ {children} +
+
+ + + +
+ ); +}; + +export default Layout; diff --git a/utils/gui/frontend/src/components/LoadingSpinner.tsx b/utils/gui/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000000..485dae456b --- /dev/null +++ b/utils/gui/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,35 @@ +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; +} + +const LoadingSpinner = ({ size = 'medium' }: LoadingSpinnerProps) => { + const sizeStyles = { + small: { width: '16px', height: '16px', borderWidth: '2px' }, + medium: { width: '24px', height: '24px', borderWidth: '3px' }, + large: { width: '40px', height: '40px', borderWidth: '4px' }, + }; + + return ( +
+ ); +}; + +// Add keyframes to CSS +const style = document.createElement('style'); +style.textContent = ` + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } +`; +document.head.appendChild(style); + +export default LoadingSpinner; diff --git a/utils/gui/frontend/src/components/NotFound.tsx b/utils/gui/frontend/src/components/NotFound.tsx new file mode 100644 index 0000000000..cd02457923 --- /dev/null +++ b/utils/gui/frontend/src/components/NotFound.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom'; + +export const NotFound = () => { + return ( +
+

Page Not Found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + Go to Overview + +
+ ); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx b/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx new file mode 100644 index 0000000000..eeba996d77 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx @@ -0,0 +1,394 @@ +import { useState, useEffect, useRef } from 'react'; +import Layout from '../../components/Layout'; +import Button from '../../components/Button'; +import { adapterPolicySchema, type AdapterPolicyFormData } from './schemas/adapterPolicy'; +import { showAlert } from '../toast/toastStore'; +import { showConfirm } from '../confirmDialog/confirmDialogStore'; +import { useAdapterPolicy, useSaveAdapterPolicy, useDeleteAdapterPolicy } from './hooks/useAdapterPolicy'; +import { TargetListSection } from './components/TargetListSection'; +import { TargetEditSection } from './components/TargetEditSection'; +import { AddTargetSection } from './components/AddTargetSection'; + +const DEFAULT_POLICY: AdapterPolicyFormData = { + version: '2.0.0', + description: 'Adapter policy for package transformation', + targets: {}, +}; + +export const AdapterPolicyEditor = () => { + const [policy, setPolicy] = useState(DEFAULT_POLICY); + const [selectedTarget, setSelectedTarget] = useState(null); + const [errors, setErrors] = useState>({}); + const [newTargetName, setNewTargetName] = useState(''); + const [newTargetNameError, setNewTargetNameError] = useState(''); + const [saveError, setSaveError] = useState(''); + const [policySource, setPolicySource] = useState<'custom' | 'default'>('default'); + const [hasInitialized, setHasInitialized] = useState(false); + const editSectionRef = useRef(null); + + const { data: policyData, isLoading, error: loadError } = useAdapterPolicy(); + const saveMutation = useSaveAdapterPolicy(); + const deleteMutation = useDeleteAdapterPolicy(); + + // Sync policy from API response + useEffect(() => { + if (policyData) { + if (policyData && policyData.policy) { + setPolicy(policyData.policy); + setPolicySource(policyData.source || 'default'); + } else { + setPolicy(DEFAULT_POLICY); + setPolicySource('default'); + } + setHasInitialized(true); + } else if (loadError) { + setPolicy(DEFAULT_POLICY); + setPolicySource('default'); + setHasInitialized(true); + } + }, [policyData, loadError]); + + // Scroll to edit section when target is selected + useEffect(() => { + if (selectedTarget && editSectionRef.current) { + editSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [selectedTarget]); + + // Real-time validation + useEffect(() => { + const result = adapterPolicySchema.safeParse(policy); + if (!result.success) { + const errorMessages: Record = {}; + result.error.issues.forEach((err) => { + const path = err.path.join('.'); + errorMessages[path] = err.message; + }); + setErrors(errorMessages); + } else { + setErrors({}); + } + // Clear save error when user edits + setSaveError(''); + }, [policy]); + + const addTarget = () => { + if (!newTargetName.trim()) { + setNewTargetNameError('Target filename is required'); + return; + } + + // Validate target name format + if (!newTargetName.endsWith('.json')) { + setNewTargetNameError('Target filename must end with .json'); + return; + } + + // Validate that there's content before .json extension + const nameWithoutExtension = newTargetName.slice(0, -5); + if (!nameWithoutExtension.trim()) { + setNewTargetNameError('Target filename must have a name before .json extension'); + return; + } + + setPolicy((prev) => ({ + ...prev, + targets: { + ...prev.targets, + [newTargetName]: { + transform: { exclude_fields: ['architecture'] }, + sources: [], + derived: [], + }, + }, + })); + setSelectedTarget(newTargetName); + setNewTargetName(''); + setNewTargetNameError(''); + }; + + const addSource = (targetName: string) => { + setPolicy((prev: AdapterPolicyFormData) => ({ + ...prev, + targets: { + ...prev.targets, + [targetName]: { + ...prev.targets[targetName], + sources: [ + ...(prev.targets[targetName]?.sources || []), + { + source_file: 'functional_layer.json', + pulls: [], + }, + ], + }, + }, + })); + }; + + const removeSource = (targetName: string, sourceIndex: number) => { + setPolicy((prev: AdapterPolicyFormData) => { + const target = { ...prev.targets[targetName] }; + const sources = target.sources.filter((_, idx) => idx !== sourceIndex); + target.sources = sources; + return { + ...prev, + targets: { ...prev.targets, [targetName]: target }, + }; + }); + }; + + const addPull = (targetName: string, sourceIndex: number) => { + setPolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const sources = [...target.sources]; + const source = { ...sources[sourceIndex] }; + source.pulls = [...(source.pulls || []), { source_key: '', target_key: '' }]; + sources[sourceIndex] = source; + target.sources = sources; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }; + + const removePull = (targetName: string, sourceIndex: number, pullIndex: number) => { + setPolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const sources = [...target.sources]; + const source = { ...sources[sourceIndex] }; + source.pulls = source.pulls?.filter((_, idx) => idx !== pullIndex) || []; + sources[sourceIndex] = source; + target.sources = sources; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }; + + const addDerived = (targetName: string) => { + setPolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + target.derived = [...(target.derived || []), { + target_key: '', + operation: { + type: 'extract_common', + from_keys: [], + min_occurrences: 2, + remove_from_sources: true, + }, + }]; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }; + + const removeDerived = (targetName: string, derivedIndex: number) => { + setPolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + target.derived = target.derived?.filter((_, idx) => idx !== derivedIndex) || []; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }; + + const removeTarget = (targetName: string) => { + console.log('Removing target:', targetName, 'Current targets:', Object.keys(policy.targets)); + setPolicy((prev: AdapterPolicyFormData) => { + const newTargets = { ...prev.targets }; + delete newTargets[targetName]; + console.log('After deletion:', Object.keys(newTargets)); + return { ...prev, targets: newTargets }; + }); + if (selectedTarget === targetName) { + setSelectedTarget(null); + } + }; + + const onSubmit = async () => { + const result = adapterPolicySchema.safeParse(policy); + if (!result.success) { + const errorMessages: Record = {}; + result.error.issues.forEach((err) => { + const path = err.path.join('.'); + errorMessages[path] = err.message; + }); + setErrors(errorMessages); + + // Show showAlert for root-level errors that don't map to UI fields + const rootErrors = result.error.issues.filter(err => err.path.length === 0); + if (rootErrors.length > 0) { + setSaveError(rootErrors.map(err => err.message).join(', ')); + } else { + // Show the actual field-level errors instead of generic message + const errorList = Object.entries(errorMessages) + .map(([field, message]) => `${field}: ${message}`) + .join('; '); + setSaveError(`Please fix the validation errors: ${errorList}`); + } + return; + } + + setSaveError(''); + try { + await saveMutation.mutateAsync(policy); + setPolicySource('custom'); + showAlert('Adapter policy saved successfully!'); + } catch (error) { + console.error('Failed to save adapter policy:', error); + setSaveError('Failed to save adapter policy. Please try again.'); + } + }; + + const handleRevertToDefault = () => { + showConfirm( + 'Revert to Default', + 'Are you sure you want to revert to the default adapter policy? This will delete your custom policy.', + async () => { + try { + await deleteMutation.mutateAsync(); + setPolicy(DEFAULT_POLICY); + setPolicySource('default'); + showAlert('Reverted to default adapter policy'); + } catch (error) { + console.error('Failed to revert to default policy:', error); + showAlert('Failed to revert to default policy. Please try again.'); + } + } + ); + }; + + const isPolicyValid = () => { + const result = adapterPolicySchema.safeParse(policy); + return result.success; + }; + + const selectedTargetData = selectedTarget ? policy.targets[selectedTarget] : null; + + return ( + +
+
+

Adapter Policy Editor

+

+ Create and edit adapter policies to transform source JSON files into target JSON files. +

+
+ + {isLoading && ( +
+
Loading adapter policy...
+
+ )} + + {loadError && ( +
+ {loadError instanceof Error ? loadError.message : 'Failed to load adapter policy. Using default template.'} +
+ )} + + {!isLoading && hasInitialized && ( + <> +
+
+ + Current policy: {policySource === 'custom' ? 'Custom' : 'Default'} + +
+ {policySource === 'custom' && ( + + )} +
+ +
+
+ + setPolicy({ ...policy, description: e.target.value })} + placeholder="Describe this adapter policy" + /> + {errors['description'] && ( +
{errors['description']}
+ )} +
+ +
+ + + + + {/* Edit section when target is selected */} + {selectedTarget && selectedTargetData && ( +
+ setSelectedTarget(null)} + onAddSource={addSource} + onRemoveSource={removeSource} + onAddPull={addPull} + onRemovePull={removePull} + onAddDerived={addDerived} + onRemoveDerived={removeDerived} + errors={errors} + /> +
+ )} + + {/* Add new target section */} + { + setNewTargetName(name); + setNewTargetNameError(''); + }} + onAdd={addTarget} + error={newTargetNameError} + /> +
+
+ +
+ + +
+ {saveError && ( +
+ {saveError} +
+ )} + + )} +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx b/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx new file mode 100644 index 0000000000..8182063ab5 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx @@ -0,0 +1,32 @@ +import Button from '../../../components/Button'; + +interface AddTargetSectionProps { + newTargetName: string; + onNameChange: (name: string) => void; + onAdd: () => void; + error: string; +} + +export const AddTargetSection = ({ newTargetName, onNameChange, onAdd, error }: AddTargetSectionProps) => { + return ( + <> +
+ { + onNameChange(e.target.value); + }} + placeholder="Enter target filename (e.g., service_k8s.json)" + /> + +
+ {error && ( +
{error}
+ )} + + ); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx b/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx new file mode 100644 index 0000000000..eaf6c99232 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx @@ -0,0 +1,408 @@ +import { useRef } from 'react'; +import Button from '../../../components/Button'; +import type { AdapterPolicyFormData } from '../schemas/adapterPolicy'; + +const FILTER_TYPES = [ + { value: 'substring', label: 'Substring Match' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'field_in', label: 'Field In' }, + { value: 'any_of', label: 'Composite (Any Of)' }, +]; + +interface TargetEditSectionProps { + targetName: string; + targetData: AdapterPolicyFormData['targets'][string]; + policy: AdapterPolicyFormData; + onPolicyChange: (policy: AdapterPolicyFormData) => void; + onClose: () => void; + onAddSource: (targetName: string) => void; + onRemoveSource: (targetName: string, sourceIdx: number) => void; + onAddPull: (targetName: string, sourceIdx: number) => void; + onRemovePull: (targetName: string, sourceIdx: number, pullIdx: number) => void; + onAddDerived: (targetName: string) => void; + onRemoveDerived: (targetName: string, derivedIdx: number) => void; + errors: Record; +} + +export const TargetEditSection = ({ + targetName, + targetData, + policy, + onPolicyChange, + onClose, + onAddSource, + onRemoveSource, + onAddPull, + onRemovePull, + onAddDerived, + onRemoveDerived, + errors, +}: TargetEditSectionProps) => { + const editSectionRef = useRef(null); + + const updatePolicy = (updater: (policy: AdapterPolicyFormData) => AdapterPolicyFormData) => { + onPolicyChange(updater(policy)); + }; + + return ( +
+

Edit: {targetName}

+ +
+
+ +
+ +
+
+ +
+ + {targetData.sources?.map((source, sourceIdx) => ( +
+
+

Source {sourceIdx + 1}

+ +
+ +
+ + + {errors[`targets.${targetName}.sources.${sourceIdx}.source_file`] && ( +
{errors[`targets.${targetName}.sources.${sourceIdx}.source_file`]}
+ )} +
+ +
+ + {source.pulls?.map((pull, pullIdx) => ( +
+
+
Pull {pullIdx + 1}
+ +
+
+
+ + { + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const sources = [...target.sources]; + const source = { ...sources[sourceIdx] }; + const pulls = [...source.pulls]; + pulls[pullIdx] = { ...pulls[pullIdx], source_key: e.target.value }; + source.pulls = pulls; + sources[sourceIdx] = source; + target.sources = sources; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + placeholder="e.g., name" + /> + {errors[`targets.${targetName}.sources.${sourceIdx}.pulls.${pullIdx}.source_key`] && ( +
{errors[`targets.${targetName}.sources.${sourceIdx}.pulls.${pullIdx}.source_key`]}
+ )} +
+
+ + { + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const sources = [...target.sources]; + const source = { ...sources[sourceIdx] }; + const pulls = [...source.pulls]; + pulls[pullIdx] = { ...pulls[pullIdx], target_key: e.target.value }; + source.pulls = pulls; + sources[sourceIdx] = source; + target.sources = sources; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + placeholder="e.g., name" + /> + {errors[`targets.${targetName}.sources.${sourceIdx}.pulls.${pullIdx}.target_key`] && ( +
{errors[`targets.${targetName}.sources.${sourceIdx}.pulls.${pullIdx}.target_key`]}
+ )} +
+
+ +
+ + +
+ + {pull.filter?.type && pull.filter?.type !== 'any_of' && ( +
+ + { + const values = e.target.value.split(',').map(v => v.trim()).filter(v => v); + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const sources = [...target.sources]; + const source = { ...sources[sourceIdx] }; + const pulls = [...source.pulls]; + const newPull = { ...pulls[pullIdx] }; + newPull.filter = { ...newPull.filter, values }; + pulls[pullIdx] = newPull; + source.pulls = pulls; + sources[sourceIdx] = source; + target.sources = sources; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + placeholder="e.g., value1, value2, value3" + /> +
+ )} + + {pull.filter?.type === 'any_of' && ( +
+ +
+ {pull.filter.filters?.length || 0} filters configured +
+
+ )} +
+ ))} + +
+
+ ))} + +
+ +
+ + {targetData.derived?.map((derived, derivedIdx) => ( +
+
+

Derived Operation {derivedIdx + 1}

+ +
+ +
+
+ + { + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const derivedOps = [...(target.derived || [])]; + derivedOps[derivedIdx] = { ...derivedOps[derivedIdx], target_key: e.target.value }; + target.derived = derivedOps; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + placeholder="e.g., common_name" + /> +
+
+ + { + const fromKeys = e.target.value.split(',').map(k => k.trim()).filter(k => k); + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const derivedOps = [...(target.derived || [])]; + derivedOps[derivedIdx] = { + ...derivedOps[derivedIdx], + operation: { ...derivedOps[derivedIdx].operation, from_keys: fromKeys }, + }; + target.derived = derivedOps; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + placeholder="e.g., key1, key2, key3" + /> +
+
+ +
+
+ + { + updatePolicy((prev: AdapterPolicyFormData) => { + const newPolicy = { ...prev }; + const target = { ...newPolicy.targets[targetName] }; + const derivedOps = [...(target.derived || [])]; + derivedOps[derivedIdx] = { + ...derivedOps[derivedIdx], + operation: { ...derivedOps[derivedIdx].operation, min_occurrences: parseInt(e.target.value) }, + }; + target.derived = derivedOps; + newPolicy.targets[targetName] = target; + return newPolicy; + }); + }} + min="1" + /> +
+
+ + +
+
+
+ ))} + +
+
+ +
+ +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx b/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx new file mode 100644 index 0000000000..289b7841c8 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx @@ -0,0 +1,34 @@ +import Button from '../../../components/Button'; +import type { AdapterPolicyFormData } from '../schemas/adapterPolicy'; + +interface TargetListSectionProps { + targets: AdapterPolicyFormData['targets']; + onEditTarget: (targetName: string) => void; + onRemoveTarget: (targetName: string) => void; +} + +export const TargetListSection = ({ targets, onEditTarget, onRemoveTarget }: TargetListSectionProps) => { + return ( +
+ {Object.keys(targets).map((targetName) => ( +
+
+

{targetName}

+
+ + +
+
+
+ Sources: {targets[targetName].sources?.length || 0} | + Derived: {targets[targetName].derived?.length || 0} +
+
+ ))} +
+ ); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts b/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts new file mode 100644 index 0000000000..7cafc38587 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts @@ -0,0 +1,44 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { AdapterPolicyFormData } from '../schemas/adapterPolicy'; + +export const useAdapterPolicy = () => + useQuery({ + queryKey: ['adapter-policy'], + queryFn: async () => { + const res = await fetch('/api/v1/adapter-policy'); + if (!res.ok) throw new Error('Failed to load adapter policy'); + const data = await res.json(); + return data; + }, + }); + +export const useSaveAdapterPolicy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (policy: AdapterPolicyFormData) => { + const res = await fetch('/api/v1/adapter-policy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + if (!res.ok) throw new Error('Failed to save adapter policy'); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['adapter-policy'] }); + }, + }); +}; + +export const useDeleteAdapterPolicy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await fetch('/api/v1/adapter-policy', { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete custom adapter policy'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['adapter-policy'] }); + }, + }); +}; diff --git a/utils/gui/frontend/src/features/adapter-policy/index.ts b/utils/gui/frontend/src/features/adapter-policy/index.ts new file mode 100644 index 0000000000..fbc6e1df53 --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/index.ts @@ -0,0 +1 @@ +export { AdapterPolicyEditor } from './AdapterPolicyEditor' diff --git a/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts b/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts new file mode 100644 index 0000000000..c0f815b42d --- /dev/null +++ b/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; + +// Filter Config Schema +const filterConfigSchema: z.ZodType<{ + type?: 'substring' | 'allowlist' | 'field_in' | 'any_of'; + field?: string; + values?: string[]; + case_sensitive?: boolean; + filters?: any[]; +}> = z.object({ + type: z.enum(['substring', 'allowlist', 'field_in', 'any_of']).optional(), + field: z.string().optional().default('package'), + values: z.array(z.string()).optional(), + case_sensitive: z.boolean().optional().default(false), + filters: z.array(z.any()).min(1, 'At least one filter is required for any_of type').optional(), +}).refine((data) => { + // If filter type is any_of, filters must be provided + if (data.type === 'any_of' && (!data.filters || data.filters.length === 0)) { + return false; + } + // If filter type is any_of, values should not be used + if (data.type === 'any_of' && data.values && data.values.length > 0) { + return false; + } + // If filter type is not any_of, values must be provided when type is provided + if (data.type && data.type !== 'any_of' && (!data.values || data.values.length === 0)) { + return false; + } + // If filters array is provided, it must have at least one item + if (data.filters && data.filters.length === 0) { + return false; + } + return true; +}, { + message: 'Invalid filter configuration', +}); + +// Pull Config Schema +const pullConfigSchema = z.object({ + source_key: z.string().min(1, 'Source key is required'), + target_key: z.string().optional(), + filter: filterConfigSchema.optional(), + transform: z.object({ + exclude_fields: z.array(z.string()).optional(), + rename_fields: z.record(z.string(), z.string()).optional(), + }).optional(), +}).refine((data) => { + // If filter type is provided, validate target_key is also provided + if (data.filter?.type && !data.target_key) { + return false; + } + return true; +}, { + message: 'Target key is required when filter is applied', +}); + +// Source Config Schema +const sourceConfigSchema = z.object({ + source_file: z.string().min(1, 'Source file is required'), + pulls: z.array(pullConfigSchema).min(1, 'At least one pull is required'), +}); + +// Derived Operation Schema +const derivedSchema = z.object({ + target_key: z.string(), + operation: z.object({ + type: z.literal('extract_common'), + from_keys: z.array(z.string()).min(2, 'At least 2 keys are required for comparison'), + min_occurrences: z.number().default(2), + remove_from_sources: z.boolean().default(true), + }), +}); + +// Target Config Schema +const targetConfigSchema = z.object({ + transform: z.object({ + exclude_fields: z.array(z.string()).optional(), + rename_fields: z.record(z.string(), z.string()).optional(), + }).optional(), + sources: z.array(sourceConfigSchema).min(1, 'At least one source is required'), + derived: z.array(derivedSchema).optional(), + conditions: z.object({ + architectures: z.array(z.string()).optional(), + os_versions: z.array(z.string()).optional(), + os_families: z.array(z.string()).optional(), + }).optional(), +}); + +// Adapter Policy Schema +export const adapterPolicySchema = z.object({ + version: z.string().default('2.0.0'), + description: z.string().optional(), + architectures: z.array(z.string()).min(1).optional().refine((arr) => { + if (!arr) return true; + const unique = new Set(arr); + return unique.size === arr.length; + }, { + message: 'Architectures must be unique', + }), + targets: z.record(z.string(), targetConfigSchema), +}).refine((data) => { + // Ensure at least one target is defined + if (!data.targets || Object.keys(data.targets).length === 0) { + return false; + } + return true; +}, { + message: 'At least one target is required', +}); + +export type AdapterPolicyFormData = z.infer; + + + + + + diff --git a/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css b/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css new file mode 100644 index 0000000000..67d5779038 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css @@ -0,0 +1,215 @@ +/* Catalog Editor Specific Styles */ + +.catalog-header { + margin-bottom: 24px; +} + +.catalog-toolbar { + display: flex; + gap: 12px; + margin-bottom: 24px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; + overflow: hidden; + flex-wrap: wrap; +} + +.catalog-sidebar { + width: 250px; + background-color: #f9f9f9; + padding: 16px; + border-radius: 8px; + flex-shrink: 0; +} + +.catalog-sidebar h3 { + margin: 0 0 16px 0; + color: #2c3e50; + font-size: 16px; + font-weight: 600; +} + +.catalog-content { + flex: 1; + overflow-y: auto; +} + +.catalog-content .card { + overflow: hidden; +} + +.catalog-content .table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.catalog-content .table th, +.catalog-content .table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #e0e0e0; + overflow: hidden; + text-overflow: ellipsis; +} + +.catalog-content .table th:nth-child(2), +.catalog-content .table td:nth-child(2) { + white-space: nowrap; +} + +.catalog-content .table th { + background-color: #f5f5f5; + font-weight: 600; + color: #2c3e50; + font-size: 13px; +} + +.catalog-content .table td { + font-size: 13px; + color: #546e7a; +} + +.catalog-content .table tbody tr:hover { + background-color: #f9f9f9; +} + +.catalog-content .table th:nth-child(1), +.catalog-content .table td:nth-child(1) { + width: 15%; + white-space: normal; + word-wrap: break-word; +} + +.catalog-content .table th:nth-child(2), +.catalog-content .table td:nth-child(2) { + width: 40%; +} + +.catalog-content .table th:nth-child(3), +.catalog-content .table td:nth-child(3) { + width: 15%; +} + +.catalog-content .table th:nth-child(4), +.catalog-content .table td:nth-child(4) { + width: 15%; +} + +.catalog-content .table th:nth-child(5), +.catalog-content .table td:nth-child(5) { + width: 25%; +} + +.package-table-container { + max-height: 600px; + overflow-y: auto; +} + +.package-id-cell { + font-family: 'Courier New', monospace; + font-size: 13px; +} + +.package-type-badge { + display: inline-block; + padding: 2px 8px; + background-color: #e0f4f8; + color: #0097a7; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.add-form-container { + margin-bottom: 16px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; +} + +.validation-panel { + padding: 24px; +} + +.validation-error-item { + color: #dc3545; + padding: 8px; + margin-bottom: 8px; + background-color: #fde8e8; + border-radius: 4px; + display: flex; + align-items: center; +} + +.validation-error-item::before { + content: '[E]'; + margin-right: 8px; +} + +.validation-warning-item { + color: #e65100; + padding: 8px; + margin-bottom: 8px; + background-color: #fff3e0; + border-radius: 4px; + display: flex; + align-items: center; +} + +.validation-warning-item::before { + content: '[W]'; + margin-right: 8px; +} + + +.bundle-card { + background-color: #ffffff; + border-radius: 8px; + padding: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 100%; + cursor: pointer; + margin-bottom: 0; +} + +.bundle-card-selected { + border: 2px solid #4caf50; +} + +.bg-gray-light { + background-color: #f9f9f9; +} + +.text-dark { + color: #333; +} + +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 150px; + display: inline-block; + flex: 1; + margin-right: 8px; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.button-sm { + padding: 4px 8px; + font-size: 12px; +} + +.button-xs { + padding: 2px 6px; + font-size: 10px; +} diff --git a/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx new file mode 100644 index 0000000000..71a40d7c61 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx @@ -0,0 +1,263 @@ +import { + useValidateCatalog, + useImportCatalog, +} from './hooks/useCatalog'; +import { useCatalogStore } from './catalogStore'; +import { useConfigStore } from '../configuration-wizard/configStore'; +import { showConfirm } from '../confirmDialog/confirmDialogStore'; +import { useNavigate } from 'react-router-dom'; +import Layout from '../../components/Layout'; +import CatalogOverview from './components/CatalogOverview'; +import FunctionalLayerEditor from './components/FunctionalLayerEditor'; +import OSPackageEditor from './components/OSPackageEditor'; +import InfrastructureEditor from './components/InfrastructureEditor'; +import DriverPackageEditor from './components/DriverPackageEditor'; +import MiscellaneousEditor from './components/MiscellaneousEditor'; +import ValidationPanel from './components/ValidationPanel'; +import { extractUserFriendlyErrorMessage } from './utils/extractErrorMessage'; +import { EMPTY_CATALOG } from './constants/emptyCatalog'; +import { cleanCatalogForExport } from './utils/cleanCatalogForExport'; +import { useMemo } from 'react'; +import './CatalogEditor.css'; + +const CatalogEditor = () => { + const validateCatalog = useValidateCatalog(); + const importCatalog = useImportCatalog(); + const navigate = useNavigate(); + const setWizardData = useConfigStore((s) => s.setWizardData); + const setActiveStep = useConfigStore((s) => s.setActiveStep); + const resetWizard = useConfigStore((s) => s.resetWizard); + const setConfigSource = useConfigStore((s) => s.setConfigSource); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const activeSection = useCatalogStore((s) => s.activeSection); + const setActiveSection = useCatalogStore((s) => s.setActiveSection); + const validationErrors = useCatalogStore((s) => s.validationErrors); + const validationWarnings = useCatalogStore((s) => s.validationWarnings); + const setValidationResults = useCatalogStore((s) => s.setValidationResults); + + // Convenience: the inner catalog data + const inner = catalogRoot?.Catalog; + + const handleValidate = async () => { + if (!catalogRoot) return; + try { + const result = + await validateCatalog.mutateAsync(catalogRoot); + setValidationResults(result.errors, result.warnings); + setActiveSection('validation'); + } catch (err: any) { + console.error('Validation failed:', err); + + // Extract user-friendly error message + const errorMessage = extractUserFriendlyErrorMessage(err); + + // Show schema validation errors as L1 errors + setValidationResults([errorMessage], []); + setActiveSection('validation'); + } + }; + + const handleExport = () => { + if (!catalogRoot) return; + const cleanedCatalog = cleanCatalogForExport(catalogRoot); + const blob = new Blob( + [JSON.stringify(cleanedCatalog, null, 2)], + { type: 'application/json' }, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'catalog_rhel.json'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const handleImport = ( + e: React.ChangeEvent, + ) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Add file size limit (100 MB) + const MAX_IMPORT_SIZE = 100 * 1024 * 1024; + if (file.size > MAX_IMPORT_SIZE) { + setValidationResults(['File too large (max 100 MB)'], []); + setActiveSection('validation'); + return; + } + + const reader = new FileReader(); + reader.onload = async (ev) => { + try { + const imported = JSON.parse( + ev.target?.result as string, + ); + // Check if this is a catalog file (has "Catalog" key) + if ('Catalog' in imported) { + // Validate structure before loading into state + try { + const result = await validateCatalog.mutateAsync(imported); + if (result.errors.length > 0) { + setValidationResults(result.errors, result.warnings); + setActiveSection('validation'); + return; + } + } catch (validationErr) { + console.error('Validation during import failed:', validationErr); + const errMsg = extractUserFriendlyErrorMessage(validationErr); + setValidationResults( + [`Import validation failed: ${errMsg}`], + ['Catalog loaded without validation — please validate manually'], + ); + setActiveSection('validation'); + // Still load — but user is warned + } + + // Load into catalog editor store for immediate editing + setCatalogRoot(imported); + // Also import to backend to sync in-memory catalog + try { + await importCatalog.mutateAsync(imported); + } catch (err) { + console.error('Failed to import catalog to backend:', err); + // Don't block UI - local state is still updated + } + } else { + // Load into wizard + setWizardData(imported); + setActiveStep(1); + navigate('/wizard'); + } + } catch (err) { + console.error('Import failed:', err); + setValidationResults( + [err instanceof Error ? err.message : 'Failed to parse JSON file'], + [], + ); + setActiveSection('validation'); + } + }; + reader.readAsText(file); + e.target.value = ''; + }; + + const handleResetAll = () => { + showConfirm( + 'Reset All', + 'This will clear the current catalog and all deployment configuration data. This cannot be undone.', + () => { + setConfigSource('fresh'); + resetWizard(); + setCatalogRoot(EMPTY_CATALOG); + setActiveSection('overview'); + setValidationResults([], []); + } + ); + }; + + const layerCount = inner?.FunctionalLayer.length ?? 0; + const osPkgCount = Object.keys( + inner?.OSPackages ?? {}, + ).length; + const infraPkgCount = Object.keys( + inner?.InfrastructurePackages ?? {}, + ).length; + + const validationIcon = + validationErrors.length > 0 + ? '(E)' + : validationWarnings.length > 0 + ? '(W)' + : '(OK)'; + + const sections = useMemo(() => [ + ['overview', 'Overview'], + ['layers', `Functional Layers (${layerCount})`], + ['os', `OS Packages (${osPkgCount})`], + ['infrastructure', `Infrastructure Packages (${infraPkgCount})`], + ['driver-packages', 'Driver Packages'], + ['miscellaneous', 'Miscellaneous'], + ['validation', `Validation ${validationIcon}`], + ] as const, [layerCount, osPkgCount, infraPkgCount, validationIcon]); + + return ( + +
+ {/* Header */} +
+

Catalog Manager

+
+ + {/* Action Bar */} +
+ + + + +
+ + {/* Sidebar + Content */} +
+
+

Sections

+
+ {sections.map(([section, label]) => ( + + ))} +
+
+ +
+ {activeSection === 'overview' && ( + + )} + {activeSection === 'layers' && ( + + )} + {activeSection === 'os' && } + {activeSection === 'infrastructure' && ( + + )} + {activeSection === 'driver-packages' && } + {activeSection === 'miscellaneous' && } + {activeSection === 'validation' && ( + + )} +
+
+
+
+ ); +}; + +export default CatalogEditor; diff --git a/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts b/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts new file mode 100644 index 0000000000..691118799e --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts @@ -0,0 +1,59 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { CatalogRoot } from './schemas/catalogSchema'; + +type ActiveSection = + | 'overview' + | 'layers' + | 'os' + | 'infrastructure' + | 'driver-packages' + | 'miscellaneous' + | 'validation'; + +interface CatalogState { + // Data + catalogRoot: CatalogRoot | null; + setCatalogRoot: (root: CatalogRoot) => void; + + // UI + activeSection: ActiveSection; + setActiveSection: (section: ActiveSection) => void; + + // Validation + validationErrors: string[]; + validationWarnings: string[]; + setValidationResults: ( + errors: string[], + warnings: string[], + ) => void; +} + +export const useCatalogStore = create()( + persist( + (set) => ({ + catalogRoot: null, + setCatalogRoot: (root) => set({ catalogRoot: root }), + + activeSection: 'overview', + setActiveSection: (section) => + set({ activeSection: section }), + + validationErrors: [], + validationWarnings: [], + setValidationResults: (errors, warnings) => + set({ + validationErrors: errors, + validationWarnings: warnings, + }), + }), + { + name: 'catalog-editor-storage', + // Persist catalog data and navigation state to survive page refreshes + partialize: (state) => ({ + catalogRoot: state.catalogRoot, + activeSection: state.activeSection, + }), + }, + ), +); diff --git a/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx b/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx new file mode 100644 index 0000000000..d02c29f9d1 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { useAvailableBundles } from '../hooks/useBundleSelection'; + +interface BundleSelectorProps { + arch: string; + osFamily: string; + version: string; + selectedBundles: Set; + onBundleToggle: (bundleName: string, required: boolean) => void; + bundleMetadata?: Record; + showOnlyType?: 'functional' | 'infrastructure' | 'os' | 'all'; + expandedBundles?: Set; + onBundleExpand?: (bundleName: string) => void; + selectedPackages?: Record>; + onPackageToggle?: (bundleName: string, packageId: string) => void; + bundlePackageData?: Record>; +} + +const BundleSelector = ({ + arch, + osFamily, + version, + selectedBundles, + onBundleToggle, + bundleMetadata = {}, + showOnlyType = 'all', + expandedBundles = new Set(), + onBundleExpand, + selectedPackages = {}, + onPackageToggle, + bundlePackageData = {} +}: BundleSelectorProps) => { + const [searchQuery, setSearchQuery] = useState(''); + + const { data: bundles, isLoading, error } = useAvailableBundles(arch, osFamily, version); + + const filteredBundles = bundles?.filter(bundle => { + if (showOnlyType !== 'all' && bundle.type !== showOnlyType) { + return false; + } + if (searchQuery && !bundle.name.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + return true; + }) || []; + + if (isLoading) { + return

Loading bundles...

; + } + + if (error) { + return

Failed to load bundles

; + } + + if (filteredBundles.length === 0) { + return

No bundles found

; + } + + return ( +
+
+ + setSearchQuery(e.target.value)} + className="form-input" + /> +
+ +
+ {filteredBundles.map(bundle => { + const metadata = bundleMetadata[bundle.name] || { required: false, description: '' }; + const isSelected = selectedBundles.has(bundle.name); + + return ( +
+
+ + {onBundleExpand && ( + + )} +
+ +
+ {bundle.package_count} packages +
+ +
+ {bundle.type} +
+ + {metadata.description && ( +

+ {metadata.description} +

+ )} + + {expandedBundles.has(bundle.name) && bundlePackageData[bundle.name] && ( +
+
+ Select packages: +
+ {Object.entries(bundlePackageData[bundle.name]).map(([section, packages]: [string, any[]]) => ( +
+
+ {section} +
+ {packages.map((pkg: any) => { + const pkgId = `${pkg.package}_${pkg.type}`; + const bundlePkgs = selectedPackages[bundle.name] || new Set(); + const isPkgSelected = bundlePkgs.has(pkgId); + + return ( + + ); + })} +
+ ))} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default BundleSelector; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx b/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx new file mode 100644 index 0000000000..407292c8a0 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx @@ -0,0 +1,92 @@ +import { useCatalogStore } from '../catalogStore'; + +const CatalogOverview = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + + if (!catalogRoot) return

No catalog loaded

; + + const inner = catalogRoot.Catalog; + + const updateMetadata = ( + field: 'Name' | 'Version' | 'Identifier', + value: string, + ) => { + setCatalogRoot({ + ...catalogRoot, + Catalog: { ...inner, [field]: value }, + }); + }; + + return ( +
+

Catalog Overview

+ +
+

Metadata

+
+
+ + + updateMetadata('Name', e.target.value) + } + className="form-input" + /> +
+
+ + + updateMetadata('Version', e.target.value) + } + className="form-input" + /> +
+
+ + + updateMetadata('Identifier', e.target.value) + } + className="form-input" + /> +
+
+
+ +
+

Statistics

+
+
+ Functional Layers:{' '} + {inner.FunctionalLayer.length} +
+
+ Functional Packages:{' '} + {Object.keys(inner.FunctionalPackages).length} +
+
+ OS Packages:{' '} + {Object.keys(inner.OSPackages).length} +
+
+ Infrastructure Packages:{' '} + { + Object.keys(inner.InfrastructurePackages) + .length + } +
+
+
+
+ ); +}; + +export default CatalogOverview; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx new file mode 100644 index 0000000000..6ab2eaf6c9 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx @@ -0,0 +1,313 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCatalogStore } from '../catalogStore'; +import { + useAddDriverPackage, + useDeleteDriverPackage, + useUpdateDriverPackage, +} from '../hooks/useCatalog'; +import { DriverPackageSchema, type DriverPackage, type PackageTypeValue } from '../schemas/catalogSchema'; +import PackageForm from './PackageForm'; +import { showConfirm } from '../../confirmDialog/confirmDialogStore'; +import { showAlert } from '../../toast/toastStore'; + +const defaultPackage: DriverPackage = { + Name: '', + Type: 'rpm' as PackageTypeValue, + Architecture: ['x86_64'], + Uri: '', + Version: '', + Config: { DriverBrand: '', DriverType: '' }, +}; + +const DriverPackageEditor: React.FC = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + const addPkg = useAddDriverPackage(); + const deletePkg = useDeleteDriverPackage(); + const updatePkg = useUpdateDriverPackage(); + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const editFormRef = useRef(null); + + const addMethods = useForm({ + resolver: zodResolver(DriverPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const editMethods = useForm({ + resolver: zodResolver(DriverPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const isDuplicateName = (name: string, excludeId?: string | null) => { + if (!catalogRoot?.Catalog?.DriverPackages) return false; + return Object.entries(catalogRoot.Catalog.DriverPackages).some( + ([id, pkg]) => pkg.Name === name && id !== excludeId + ); + }; + + const prevDriverPackageKeysRef = useRef(''); + + // Automatically derive Drivers from DriverPackages + useEffect(() => { + if (!catalogRoot?.Catalog?.DriverPackages) return; + + const driverPackageIds = Object.keys(catalogRoot.Catalog.DriverPackages).sort(); + const keysString = driverPackageIds.join(','); + if (keysString === prevDriverPackageKeysRef.current) return; + prevDriverPackageKeysRef.current = keysString; + + const driverPackages = catalogRoot.Catalog.DriverPackages; + + // Group driver packages by DriverBrand and DriverType + const driverGroups = new Map(); + + Object.entries(driverPackages).forEach(([pkgId, pkg]) => { + const brand = pkg.Config.DriverBrand || 'unknown'; + const type = pkg.Config.DriverType || 'unknown'; + const key = `${brand}_${type}`; + + if (!driverGroups.has(key)) { + driverGroups.set(key, []); + } + driverGroups.get(key)!.push(pkgId); + }); + + // Create Drivers array from grouped packages + const drivers = Array.from(driverGroups.entries()).map(([key, packageIds]) => { + const [brand, type] = key.split('_'); + return { + Name: `${brand} ${type}`, + DriverPackages: packageIds.sort() + }; + }); + + // Update Drivers section + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + Drivers: drivers + } + }; + + setCatalogRoot(updatedCatalog); + }, [catalogRoot?.Catalog?.DriverPackages, setCatalogRoot]); + + if (!catalogRoot) return

No catalog loaded

; + const inner = catalogRoot.Catalog; + const packages = Object.entries(inner.DriverPackages); + + + const handleAddPackage = async (data: DriverPackage) => { + if (isDuplicateName(data.Name)) { + addMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data } as any; + if (!payload.Uri?.trim()) delete payload.Uri; + if (!payload.Version?.trim()) delete payload.Version; + + try { + await addPkg.mutateAsync(payload); + addMethods.reset(defaultPackage); + setShowAddForm(false); + if (catalogRoot && catalogRoot.Catalog.DriverPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + DriverPackages: { + ...catalogRoot.Catalog.DriverPackages, + [payload.Name]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to add driver package to backend:', err); + showAlert('Failed to add driver package to backend'); + } + }; + + const handleEditPackage = (id: string, pkg: DriverPackage) => { + setEditingId(id); + editMethods.reset(pkg); + setShowAddForm(false); // Close add form if open + }; + + const handleSavePackage = async (data: DriverPackage) => { + if (!editingId) return; + if (isDuplicateName(data.Name, editingId)) { + editMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data } as any; + if (!payload.Uri?.trim()) delete payload.Uri; + if (!payload.Version?.trim()) delete payload.Version; + + try { + try { + await updatePkg.mutateAsync({ packageId: editingId, pkg: payload }); + } catch (updateErr) { + if ((updateErr as any).status === 404) { + await addPkg.mutateAsync(payload); + } else { + throw updateErr; + } + } + setEditingId(null); + editMethods.reset(defaultPackage); + if (catalogRoot && catalogRoot.Catalog.DriverPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + DriverPackages: { + ...catalogRoot.Catalog.DriverPackages, + [editingId]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to save driver package to backend:', err); + showAlert('Failed to save driver package to backend'); + } + }; + + const handleDeletePackage = async (id: string) => { + showConfirm( + 'Delete Driver Package', + `Delete driver package "${id}"?`, + async () => { + try { + await deletePkg.mutateAsync(id); + } catch (err) { + console.error('Failed to delete driver package from backend, removing from local state:', err); + } + if (catalogRoot && catalogRoot.Catalog.DriverPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + DriverPackages: Object.fromEntries( + Object.entries(catalogRoot.Catalog.DriverPackages).filter(([key]) => key !== id) + ) + } + }; + setCatalogRoot(updatedCatalog); + } + } + ); + }; + + const handleCancelEdit = () => { + setEditingId(null); + editMethods.reset(defaultPackage); + }; + + const extractPackageIdNumber = (packageId: string): string => { + return packageId; + }; + + return ( +
+
+

Driver Packages

+ +
+ + {showAddForm && ( + + setShowAddForm(false)} + submitLabel={addPkg.isPending ? 'Adding…' : 'Add'} + title="Add Driver Package" + variant="driver" + /> + + )} + + {editingId && ( +
+ + + +
+ )} + +
+ + + + + + + + + + + + {packages.length === 0 ? ( + + + + ) : ( + packages.map(([id, pkg]) => ( + + + + + + + + )) + )} + +
IDNameTypeArchitectureActions
+ No driver packages defined +
{extractPackageIdNumber(id)}{pkg.Name}{pkg.Type}{pkg.Architecture.join(', ')} + + +
+
+
+ ); +}; + +export default DriverPackageEditor; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx b/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx new file mode 100644 index 0000000000..2bd250aa0a --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx @@ -0,0 +1,84 @@ +interface EmptyLayerSelectorProps { + show: boolean; + selectedLayer: string; + predefinedLayers: string[]; + customLayerName: string; + customLayerError?: string; + onLayerChange: (value: string) => void; + onCustomLayerNameChange: (value: string) => void; + onAddLayer: () => void; + onClose: () => void; +} + +export const EmptyLayerSelector = ({ + show, + selectedLayer, + predefinedLayers, + customLayerName, + customLayerError, + onLayerChange, + onCustomLayerNameChange, + onAddLayer, + onClose, +}: EmptyLayerSelectorProps) => { + if (!show) return null; + + const isCustom = selectedLayer === '__custom__'; + const isAddDisabled = !selectedLayer || (isCustom && (!customLayerName.trim() || !!customLayerError)); + + return ( +
+

Add Empty Functional Layer

+

+ Select a predefined functional layer name or choose Custom to create a named empty layer. +

+
+
+ + +
+ {isCustom && ( +
+ + onCustomLayerNameChange(e.target.value)} + placeholder="e.g. my_custom_layer_x86_64" + className="form-input" + /> + {customLayerError && ( +
{customLayerError}
+ )} +
+ )} +
+ +
+ + +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx new file mode 100644 index 0000000000..4e6ff8c316 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx @@ -0,0 +1,799 @@ +import { useState, useEffect, useRef } from 'react'; +import React from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCatalogStore } from '../catalogStore'; +import { + useUpdateFunctionalLayer, + useAddFunctionalLayer, + useAddFunctionalPackage, + useDeleteFunctionalLayer, + useDeleteFunctionalPackage, + useUpdateFunctionalPackage, +} from '../hooks/useCatalog'; +import { useAvailableRoles, useRolePackages } from '../hooks/useRoleMappings'; +import { + type FunctionalLayer, + FunctionalPackageSchema, + type FunctionalPackage, +} from '../schemas/catalogSchema'; +import PackageForm from './PackageForm'; +import { RoleSelector } from './RoleSelector'; +import { EmptyLayerSelector } from './EmptyLayerSelector'; +import { showConfirm } from '../../confirmDialog/confirmDialogStore'; +import { showAlert } from '../../toast/toastStore'; +const defaultPackage: FunctionalPackage = { + Name: '', + Type: 'rpm', + Architecture: ['x86_64'], + SupportedOS: [{ Name: 'RHEL', Version: '10.0' }], + Sources: [], + Version: '', + Tag: '', +}; +interface LayerPackageFormProps { + onSubmit: (data: FunctionalPackage) => void; + onCancel: () => void; + submitLabel: string; + title: string; + defaultValues?: FunctionalPackage; +} +const LayerPackageForm: React.FC = ({ + onSubmit, + onCancel, + submitLabel, + title, + defaultValues = defaultPackage, +}) => { + const methods = useForm({ + resolver: zodResolver(FunctionalPackageSchema), + defaultValues, + mode: 'onSubmit', + }); + useEffect(() => { + methods.reset(defaultValues); + }, [defaultValues, methods]); + return ( + + + + ); +}; +const FunctionalLayerEditor = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + const updateLayer = useUpdateFunctionalLayer(); + const addLayer = useAddFunctionalLayer(); + const deleteLayer = useDeleteFunctionalLayer(); + const deleteFunctionalPackage = useDeleteFunctionalPackage(); + const addFunctionalPackage = useAddFunctionalPackage(); + const updateFunctionalPackage = useUpdateFunctionalPackage(); + const [showRoleSelector, setShowRoleSelector] = useState(false); + const [showEmptyLayerSelector, setShowEmptyLayerSelector] = useState(false); + const [showPackagesLayer, setShowPackagesLayer] = useState(null); + const [showAddPackageForm, setShowAddPackageForm] = useState(null); + const [showEditPackageForm, setShowEditPackageForm] = useState<{ layerName: string; packageId: string } | null>(null); + const [editDefaultValues, setEditDefaultValues] = useState(defaultPackage); + const [isPopulating, setIsPopulating] = useState(false); + const [selectedLayers, setSelectedLayers] = useState(() => { + // Load selectedLayers from localStorage on initial render + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('selectedFunctionalLayers'); + return saved ? JSON.parse(saved) : []; + } + return []; + }); + const [selectedRole, setSelectedRole] = useState(''); + const [osFamily, setOsFamily] = useState('rhel'); + const [osVersion, setOsVersion] = useState('10.0'); + const [arch, setArch] = useState('x86_64'); + const editFormRef = useRef(null); + + // Predefined functional layer names from functional_groups_config.json + const PREDEFINED_FUNCTIONAL_LAYERS = [ + "os_x86_64", + "service_kube_node_x86_64", + "service_kube_control_plane_x86_64", + "login_node_x86_64", + "login_node_aarch64", + "login_compiler_node_x86_64", + "login_compiler_node_aarch64", + "slurm_control_node_x86_64", + "os_aarch64", + "slurm_node_x86_64", + "slurm_node_aarch64" + ]; + + const [selectedPredefinedLayer, setSelectedPredefinedLayer] = useState(''); + const [customLayerName, setCustomLayerName] = useState(''); + + const CUSTOM_LAYER_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]+_(x86_64|aarch64)$/; + const customLayerError = selectedPredefinedLayer === '__custom__' && customLayerName.trim() && !CUSTOM_LAYER_NAME_REGEX.test(customLayerName.trim()) + ? 'Custom layer name must start with a letter, contain only letters/numbers/underscores, and end with _x86_64 or _aarch64.' + : ''; + + const { data: roles } = useAvailableRoles(); + const { data: rolePackages, refetch: refetchRolePackages } = useRolePackages(selectedRole, arch, osFamily, osVersion); + // Sync selectedLayers with actual catalog layers on load + useEffect(() => { + if (catalogRoot && catalogRoot.Catalog.FunctionalLayer) { + const existingLayerNames = catalogRoot.Catalog.FunctionalLayer.map((l: any) => l.Name); + // Always sync selectedLayers with catalog layers when catalog changes + setSelectedLayers(existingLayerNames); + } + }, [catalogRoot]); + // Save selectedLayers to localStorage whenever it changes + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('selectedFunctionalLayers', JSON.stringify(selectedLayers)); + } + }, [selectedLayers]); + // Scroll to edit form when opened + useEffect(() => { + if (showEditPackageForm && editFormRef.current) { + editFormRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [showEditPackageForm]); + const handleAutoPopulateFromRole = async () => { + if (!selectedRole) { + showAlert('Please select a role first'); + return; + } + if (!catalogRoot) { + showAlert('No catalog loaded'); + return; + } + // Check if role architecture matches selected architecture + const roleArch = selectedRole.includes('aarch64') ? 'aarch64' : 'x86_64'; + if (roleArch !== arch) { + showAlert(`Role architecture (${roleArch}) does not match selected architecture (${arch}). Please select the correct architecture first.`); + return; + } + // Check if layer already exists in catalog + const layerAlreadyExists = catalogRoot.Catalog.FunctionalLayer.find((l: any) => l.Name === selectedRole); + if (layerAlreadyExists) { + showAlert(`Layer ${selectedRole} already exists in the catalog. Please edit the existing layer instead.`); + return; + } + setIsPopulating(true); + await refetchRolePackages(); + if (!rolePackages || Object.keys(rolePackages).length === 0) { + showAlert(`No packages found for role ${selectedRole}. The bundle files may not exist in the config directory.`); + setIsPopulating(false); + return; + } + // Create a functional layer name based on role (role already includes architecture) + const layerName = selectedRole; + + // Collect all package IDs from the role packages + const packagesToAdd: any[] = []; + for (const [_sectionName, pkgList] of Object.entries(rolePackages)) { + for (const pkg of pkgList as any[]) { + packagesToAdd.push(pkg); + } + } + + if (packagesToAdd.length === 0) { + showAlert('No packages found for role packages'); + setIsPopulating(false); + return; + } + + // Add packages to catalog first and collect the returned package IDs + const actualPackageIds: string[] = []; + const existingPackages = catalogRoot.Catalog.FunctionalPackages || {}; + const addedPackageNames = new Set(); + const updatedPackages = { ...existingPackages }; + + // Parallelize package additions with Promise.allSettled + const packageAdditionResults = await Promise.allSettled( + packagesToAdd.map(async (pkgData) => { + const existingPackageId = Object.keys(existingPackages).find( + id => { + const pkg = existingPackages[id]; + return pkg.Name === pkgData.package && + pkg.Architecture && + pkg.Architecture.includes(arch) + } + ); + if (existingPackageId) { + return { type: 'reuse', packageId: existingPackageId, packageName: pkgData.package }; + } + const sources: any[] = []; + if (pkgData.repo_name) { + sources.push({ Architecture: arch, RepoName: pkgData.repo_name }); + } + if (pkgData.url) { + sources.push({ Architecture: arch, Uri: pkgData.url }); + } + try { + const result = await addFunctionalPackage.mutateAsync({ + Name: pkgData.package, + Type: pkgData.type, + Architecture: [arch], + Version: pkgData.version || undefined, + Tag: pkgData.tag || undefined, + Sources: sources.length > 0 ? sources : undefined, + SupportedOS: [{ Name: osFamily, Version: osVersion }] + }); + return { + type: 'added', + packageId: result.package_id, + packageName: pkgData.package, + packageData: { + Name: pkgData.package, + Type: pkgData.type, + Architecture: [arch], + Version: pkgData.version || undefined, + Tag: pkgData.tag || undefined, + Sources: sources.length > 0 ? sources : undefined, + SupportedOS: [{ Name: osFamily, Version: osVersion }] + } + }; + } catch (error) { + return { type: 'failed', packageName: pkgData.package, error }; + } + }) + ); + + for (const result of packageAdditionResults) { + if (result.status === 'fulfilled') { + const { type, packageId, packageName, packageData, error } = result.value; + if (type === 'reuse' && packageId) { + actualPackageIds.push(packageId); + addedPackageNames.add(packageName || ''); + } else if (type === 'added' && packageId && packageData) { + actualPackageIds.push(packageId); + addedPackageNames.add(packageName || ''); + updatedPackages[packageId] = packageData; + } else if (type === 'failed') { + console.error(`Failed to add package ${packageName || 'unknown'}:`, error); + } + } + } + + const updatedCatalogRoot = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalPackages: updatedPackages + } + }; + + setCatalogRoot(updatedCatalogRoot); + + if (actualPackageIds.length === 0) { + showAlert('No packages were successfully added to the catalog'); + setIsPopulating(false); + return; + } + + const existingLayer = updatedCatalogRoot.Catalog.FunctionalLayer.find(l => l.Name === layerName); + + if (existingLayer) { + const updatedLayer = { + ...existingLayer, + FunctionalPackages: [...existingLayer.FunctionalPackages, ...actualPackageIds] + }; + try { + await updateLayer.mutateAsync({ layerName, layer: updatedLayer }); + setCatalogRoot({ + ...updatedCatalogRoot, + Catalog: { + ...updatedCatalogRoot.Catalog, + FunctionalLayer: updatedCatalogRoot.Catalog.FunctionalLayer.map(l => + l.Name === layerName ? updatedLayer : l + ) + } + }); + showAlert(`Updated layer ${layerName} with ${actualPackageIds.length} packages`); + } catch (error) { + console.error('Failed to update layer:', error); + showAlert('Failed to update layer in backend'); + } + } else { + const newLayer: FunctionalLayer = { + Name: layerName, + Architecture: arch, + FunctionalPackages: actualPackageIds + }; + try { + await addLayer.mutateAsync(newLayer); + setCatalogRoot({ + ...updatedCatalogRoot, + Catalog: { + ...updatedCatalogRoot.Catalog, + FunctionalLayer: [...updatedCatalogRoot.Catalog.FunctionalLayer, newLayer] + } + }); + showAlert(`Created layer ${layerName} with ${actualPackageIds.length} packages`); + } catch (err) { + console.error('Failed to add layer:', err); + if (err instanceof Error && err.message === 'Failed to add layer') { + try { + const existingLayer = updatedCatalogRoot.Catalog.FunctionalLayer.find((l: any) => l.Name === layerName); + if (existingLayer) { + const updatedLayer = { + ...existingLayer, + FunctionalPackages: [...existingLayer.FunctionalPackages, ...actualPackageIds] + }; + await updateLayer.mutateAsync({ layerName, layer: updatedLayer }); + setCatalogRoot({ + ...updatedCatalogRoot, + Catalog: { + ...updatedCatalogRoot.Catalog, + FunctionalLayer: updatedCatalogRoot.Catalog.FunctionalLayer.map(l => + l.Name === layerName ? updatedLayer : l + ) + } + }); + showAlert(`Updated existing layer ${layerName} with ${actualPackageIds.length} packages`); + } else { + setCatalogRoot({ + ...updatedCatalogRoot, + Catalog: { + ...updatedCatalogRoot.Catalog, + FunctionalLayer: [...updatedCatalogRoot.Catalog.FunctionalLayer, newLayer] + } + }); + showAlert(`Layer ${layerName} already exists in backend, added to local state`); + } + } catch (updateErr) { + console.error('Failed to update layer:', updateErr); + showAlert('Failed to add or update layer in backend'); + } + } else { + showAlert('Failed to add layer to backend'); + } + } + } + + if (!selectedLayers.includes(layerName)) { + setSelectedLayers([...selectedLayers, layerName]); + } + + setShowRoleSelector(false); + setIsPopulating(false); + }; + const inferArchitectureFromName = (name: string): string => { + if (name.includes('x86_64')) return 'x86_64'; + if (name.includes('aarch64')) return 'aarch64'; + if (name.includes('ppc64le')) return 'ppc64le'; + if (name.includes('s390x')) return 's390x'; + return 'x86_64'; // default + }; + const handleAddEmptyLayer = async () => { + if (!selectedPredefinedLayer) { + showAlert('Please select a functional layer name'); + return; + } + + if (!catalogRoot) return; + + const effectiveName = selectedPredefinedLayer === '__custom__' + ? customLayerName.trim() + : selectedPredefinedLayer; + + if (selectedPredefinedLayer === '__custom__') { + if (!effectiveName) { + showAlert('Please enter a custom layer name'); + return; + } + if (!CUSTOM_LAYER_NAME_REGEX.test(effectiveName)) { + showAlert('Custom layer name must start with a letter, contain only letters/numbers/underscores, and end with _x86_64 or _aarch64.'); + return; + } + } + + const existingLayer = catalogRoot.Catalog.FunctionalLayer?.find((l: any) => l.Name === effectiveName); + if (existingLayer) { + showAlert(`Layer "${effectiveName}" already exists`); + return; + } + + const layerArch = effectiveName.endsWith('_aarch64') ? 'aarch64' : 'x86_64'; + + const newLayer: FunctionalLayer = { + Name: effectiveName, + Architecture: layerArch, + FunctionalPackages: [] + }; + + try { + await addLayer.mutateAsync(newLayer); + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalLayer: [...(catalogRoot.Catalog.FunctionalLayer || []), newLayer] + } + }; + setCatalogRoot(updatedCatalog); + setSelectedPredefinedLayer(''); + setCustomLayerName(''); + } catch (error) { + console.error('Failed to add layer:', error); + showAlert('Failed to add layer'); + } + }; + const handleRemoveLayer = (layerName: string) => { + showConfirm( + 'Remove Layer', + `Are you sure you want to remove layer ${layerName} and all its packages from the catalog?`, + async () => { + const currentCatalog = catalogRoot; + if (!currentCatalog) { + showAlert('No catalog loaded'); + return; + } + const layer = currentCatalog.Catalog.FunctionalLayer.find((l: any) => l.Name === layerName); + if (!layer) { + showAlert('Layer not found'); + return; + } + // Packages orphaned after the layer is removed will be deleted in the cleanup pass below. + + try { + await deleteLayer.mutateAsync(layerName); + setSelectedLayers(selectedLayers.filter(name => name !== layerName)); + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalLayer: catalogRoot.Catalog.FunctionalLayer.filter((l: any) => l.Name !== layerName) + } + }; + setCatalogRoot(updatedCatalog); + const allReferencedPackageIds = new Set(); + updatedCatalog.Catalog.FunctionalLayer.forEach((l: any) => { + l.FunctionalPackages.forEach((pkgId: string) => allReferencedPackageIds.add(pkgId)); + }); + const allPackageIds = Object.keys(updatedCatalog.Catalog.FunctionalPackages || {}); + const orphanedPackageIds = allPackageIds.filter(id => !allReferencedPackageIds.has(id)); + + if (orphanedPackageIds.length > 0) { + await Promise.allSettled( + orphanedPackageIds.map(async (packageId) => { + try { + await deleteFunctionalPackage.mutateAsync(packageId); + } catch (error) { + console.error(`Failed to delete orphaned package ${packageId}:`, error); + } + }) + ); + const finalCatalog = { + ...updatedCatalog, + Catalog: { + ...updatedCatalog.Catalog, + FunctionalPackages: Object.fromEntries( + Object.entries(updatedCatalog.Catalog.FunctionalPackages || {}).filter(([id]) => !orphanedPackageIds.includes(id)) + ) + } + }; + setCatalogRoot(finalCatalog); + } + } catch (error) { + console.error('Failed to delete layer:', error); + showAlert('Failed to delete layer'); + } + } + ); + }; + const handleAddPackageToLayer = async (layerName: string, data: FunctionalPackage) => { + if (!catalogRoot) { + showAlert('No catalog loaded'); + return; + } + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + if (!payload.Sources || payload.Sources.length === 0) delete payload.Sources; + try { + const result = await addFunctionalPackage.mutateAsync(payload); + const packageId = result.package_id || payload.Name; + const packageData = result.package || payload; + const updatedPackages = { + ...(catalogRoot.Catalog?.FunctionalPackages || {}), + [packageId]: packageData + }; + let updatedCatalogRoot = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalPackages: updatedPackages + } + }; + const layer = updatedCatalogRoot.Catalog.FunctionalLayer.find((l: any) => l.Name === layerName); + if (layer) { + const updatedLayer = { + ...layer, + FunctionalPackages: [...layer.FunctionalPackages, packageId] + }; + await updateLayer.mutateAsync({ layerName, layer: updatedLayer }); + updatedCatalogRoot = { + ...updatedCatalogRoot, + Catalog: { + ...updatedCatalogRoot.Catalog, + FunctionalLayer: updatedCatalogRoot.Catalog.FunctionalLayer.map((l: any) => + l.Name === layerName ? updatedLayer : l + ) + } + }; + setCatalogRoot(updatedCatalogRoot); + } + setShowAddPackageForm(null); + } catch (error) { + console.error('Failed to add package to layer:', error); + showAlert('Failed to add package to layer'); + } + }; + const handleEditPackage = (packageId: string, layerName: string) => { + if (!catalogRoot) return; + const pkg = catalogRoot.Catalog.FunctionalPackages[packageId]; + if (pkg) { + setEditDefaultValues({ + Name: pkg.Name, + Type: pkg.Type, + Architecture: pkg.Architecture || ['x86_64'], + SupportedOS: pkg.SupportedOS || [{ Name: 'RHEL', Version: '10.0' }], + Sources: pkg.Sources || [], + Version: pkg.Version || '', + Tag: pkg.Tag || '', + }); + setShowEditPackageForm({ layerName, packageId }); + setShowAddPackageForm(null); // Close add form if open + } else { + showAlert(`Package ${packageId} not found in catalog. It may have been deleted.`); + } + }; + const handleSavePackageEdit = async (data: FunctionalPackage) => { + if (!showEditPackageForm || !catalogRoot) return; + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + if (!payload.Sources || payload.Sources.length === 0) delete payload.Sources; + try { + const result = await updateFunctionalPackage.mutateAsync({ + packageId: showEditPackageForm.packageId, + pkg: payload + }); + const updatedPackages = { + ...(catalogRoot.Catalog?.FunctionalPackages || {}), + [showEditPackageForm.packageId]: result || payload + }; + const updatedCatalogRoot = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalPackages: updatedPackages + } + }; + setCatalogRoot(updatedCatalogRoot); + setShowEditPackageForm(null); + } catch (error) { + console.error('Failed to update package:', error); + showAlert('Failed to update package'); + } + }; + const handleDeletePackageFromLayer = async (packageId: string, layerName: string) => { + showConfirm( + 'Remove Package from Layer', + `Remove package ${packageId} from layer ${layerName}?`, + async () => { + if (!catalogRoot) { + showAlert('No catalog loaded'); + return; + } + try { + const layer = catalogRoot.Catalog.FunctionalLayer.find((l: any) => l.Name === layerName); + if (layer) { + const updatedLayer = { + ...layer, + FunctionalPackages: layer.FunctionalPackages.filter((id: string) => id !== packageId) + }; + await updateLayer.mutateAsync({ layerName, layer: updatedLayer }); + // Update local catalogRoot to reflect the layer update + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + FunctionalLayer: catalogRoot.Catalog.FunctionalLayer.map((l: any) => + l.Name === layerName ? updatedLayer : l + ) + } + }; + setCatalogRoot(updatedCatalog); + // Check if package is used by other layers + const packageUsedByOtherLayers = catalogRoot.Catalog.FunctionalLayer.some((l: any) => + l.Name !== layerName && l.FunctionalPackages.includes(packageId) + ); + // If package is not used by any other layer, delete it from FunctionalPackages + if (!packageUsedByOtherLayers) { + try { + await deleteFunctionalPackage.mutateAsync(packageId); + } catch (err) { + // If backend returns 404, the package might not exist in backend + // but we should still remove it from local state + console.error('Failed to delete functional package from backend, removing from local state:', err); + } + // Update catalogRoot to remove the deleted package + const finalCatalog = { + ...updatedCatalog, + Catalog: { + ...updatedCatalog.Catalog, + FunctionalPackages: Object.fromEntries( + Object.entries(updatedCatalog.Catalog.FunctionalPackages || {}).filter(([id]) => id !== packageId) + ) + } + }; + setCatalogRoot(finalCatalog); + } + } + } catch (error) { + console.error('Failed to remove package from layer:', error); + showAlert('Failed to remove package from layer'); + } + } + ); + }; + const getPackageDisplayName = (packageId: string) => { + if (!catalogRoot) return packageId; + const pkg = catalogRoot.Catalog.FunctionalPackages[packageId]; + return pkg ? pkg.Name : packageId; + }; + if (!catalogRoot) return

No catalog loaded

; + return ( +
+
+

Functional Layers

+
+ + +
+
+ setShowRoleSelector(false)} + isLoading={isPopulating} + /> + { + setShowEmptyLayerSelector(false); + setSelectedPredefinedLayer(''); + setCustomLayerName(''); + }} + /> +
+

Selected Functional Layers ({selectedLayers.length})

+ {selectedLayers.length === 0 ? ( +

+ No functional layers selected. Click "Auto-populate from Roles" to create and select layers. +

+ ) : ( +
+ {selectedLayers.map(layerName => { + const layer = catalogRoot.Catalog.FunctionalLayer.find((l: any) => l.Name === layerName); + return ( + +
+
+
{layerName}
+
+ Architecture: {layer?.Architecture || inferArchitectureFromName(layerName)} | + Packages: {layer?.FunctionalPackages.length || 0} +
+
+
+ + +
+
+ {showPackagesLayer === layerName && layer && ( +
+
+ Packages in {layerName} ({layer.FunctionalPackages?.length || 0}): +
+ {layer.FunctionalPackages && layer.FunctionalPackages.length > 0 ? ( +
+ {layer.FunctionalPackages.map(pkgId => ( +
+ • {getPackageDisplayName(pkgId)} +
+ + +
+
+ ))} +
+ ) : ( +
No packages in this layer
+ )} +
+ +
+ {showAddPackageForm === layerName && ( + handleAddPackageToLayer(layerName, data)} + onCancel={() => setShowAddPackageForm(null)} + submitLabel="Add" + title={`Add Package to ${layerName}`} + /> + )} + {showEditPackageForm?.layerName === layerName && ( +
+ setShowEditPackageForm(null)} + submitLabel="Save" + title={`Edit Package ${showEditPackageForm.packageId}`} + defaultValues={editDefaultValues} + /> +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +}; +export default FunctionalLayerEditor; \ No newline at end of file diff --git a/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx new file mode 100644 index 0000000000..820b2a3cb8 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx @@ -0,0 +1,694 @@ +import { useState, useEffect, useRef } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCatalogStore } from '../catalogStore'; +import { + useAddInfrastructurePackage, + useDeleteInfrastructurePackage, + useUpdateInfrastructurePackage, +} from '../hooks/useCatalog'; +import { + InfrastructurePackageSchema, + type PackageTypeValue, + type InfrastructurePackage, +} from '../schemas/catalogSchema'; +import BundleSelector from './BundleSelector'; +import PackageForm from './PackageForm'; +import { showConfirm } from '../../confirmDialog/confirmDialogStore'; +import { showAlert } from '../../toast/toastStore'; + +const defaultPackage: InfrastructurePackage = { + Name: '', + Type: 'image' as PackageTypeValue, + Architecture: ['x86_64'], + Version: '', + Tag: '', + SupportedFunctions: [{ Name: 'csi' }], + Sources: [], +}; + +const InfrastructureEditor = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + const addPkg = useAddInfrastructurePackage(); + const deletePkg = useDeleteInfrastructurePackage(); + const updatePkg = useUpdateInfrastructurePackage(); + const [showAddForm, setShowAddForm] = useState(false); + const [showBundleSelector, setShowBundleSelector] = useState(false); + const [editingId, setEditingId] = useState(null); + const editFormRef = useRef(null); + + const addMethods = useForm({ + resolver: zodResolver(InfrastructurePackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const editMethods = useForm({ + resolver: zodResolver(InfrastructurePackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const isDuplicateName = (name: string, excludeId?: string | null) => { + if (!catalogRoot?.Catalog?.InfrastructurePackages) return false; + return Object.entries(catalogRoot.Catalog.InfrastructurePackages).some( + ([id, pkg]) => pkg.Name === name && id !== excludeId + ); + }; + const [osFamily, setOsFamily] = useState('rhel'); + const [osVersion, setOsVersion] = useState('10.0'); + const [arch, setArch] = useState('x86_64'); + const [selectedBundles, setSelectedBundles] = useState>(new Set()); + const [expandedBundles, setExpandedBundles] = useState>(new Set()); + const [selectedPackages, setSelectedPackages] = useState>>({}); + const [bundlePackageData, setBundlePackageData] = useState>>({}); + const [isImporting, setIsImporting] = useState(false); + const prevInfraPackageKeysRef = useRef(''); + + // Automatically derive Infrastructure from InfrastructurePackages + useEffect(() => { + if (!catalogRoot?.Catalog?.InfrastructurePackages) return; + + const infraPackageIds = Object.keys(catalogRoot.Catalog.InfrastructurePackages).sort(); + const keysString = infraPackageIds.join(','); + if (keysString === prevInfraPackageKeysRef.current) return; + prevInfraPackageKeysRef.current = keysString; + + // Update Infrastructure to match InfrastructurePackages + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + Infrastructure: [{ + Name: 'csi', + InfrastructurePackages: infraPackageIds + }] + } + }; + + setCatalogRoot(updatedCatalog); + }, [catalogRoot?.Catalog?.InfrastructurePackages, setCatalogRoot]); + + const bundleMetadata: Record = { + csi_driver_powerscale: { + required: false, + description: 'CSI driver for PowerScale storage' + } + }; + + const handleBundleToggle = async (bundleName: string, required: boolean) => { + if (required) return; + const newSelected = new Set(selectedBundles); + + if (newSelected.has(bundleName)) { + // Deselect bundle and clear individual package selections + newSelected.delete(bundleName); + setSelectedPackages(prev => { + const updated = { ...prev }; + delete updated[bundleName]; + return updated; + }); + } else { + // Select bundle and select all packages + newSelected.add(bundleName); + + // Fetch package data if not already loaded + if (!bundlePackageData[bundleName]) { + try { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundleName}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (response.ok) { + const data = await response.json(); + setBundlePackageData(prev => ({ + ...prev, + [bundleName]: data.packages + })); + + // Select all packages from the fetched data + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(data.packages)) { + for (const pkg of packages as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } catch (error) { + console.error('Failed to fetch bundle packages:', error); + } + } else { + // Select all packages from already loaded data + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(bundlePackageData[bundleName])) { + for (const pkg of packages) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } + setSelectedBundles(newSelected); + }; + + const handleBundleExpand = async (bundleName: string) => { + // Toggle expanded state + const newExpanded = new Set(expandedBundles); + if (newExpanded.has(bundleName)) { + newExpanded.delete(bundleName); + } else { + newExpanded.add(bundleName); + } + setExpandedBundles(newExpanded); + + // Fetch package data if not already loaded + if (!bundlePackageData[bundleName]) { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundleName}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (response.ok) { + const data = await response.json(); + setBundlePackageData(prev => ({ + ...prev, + [bundleName]: data.packages + })); + } + } + }; + + const handlePackageToggle = (bundleName: string, packageId: string) => { + setSelectedPackages(prev => { + const bundlePackages = prev[bundleName] || new Set(); + const newBundlePackages = new Set(bundlePackages); + if (newBundlePackages.has(packageId)) { + newBundlePackages.delete(packageId); + } else { + newBundlePackages.add(packageId); + } + return { ...prev, [bundleName]: newBundlePackages }; + }); + }; + + const handleOpenBundleSelector = async () => { + setShowBundleSelector(true); + + // Get existing infrastructure packages to initialize selection state + const existingPackages = new Map(); + if (catalogRoot?.Catalog?.InfrastructurePackages) { + for (const [_pkgId, pkg] of Object.entries(catalogRoot.Catalog.InfrastructurePackages)) { + const key = `${pkg.Name}_${pkg.Type}_${pkg.Version || ''}_${pkg.Tag || ''}`; + existingPackages.set(key, pkg); + } + } + + // Fetch all available bundles + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundles?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!response.ok) return; + + const data = await response.json(); + const bundles = data.bundles as { name: string; type: string }[]; + + // Initialize selection state based on existing packages + const newSelectedBundles = new Set(); + const newSelectedPackages: Record> = {}; + const newBundlePackageData: Record> = {}; + + const bundleResults = await Promise.allSettled( + bundles + .filter((bundle) => bundle.type === 'infrastructure') + .map(async (bundle) => { + const bundleResponse = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundle.name}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!bundleResponse.ok) throw new Error(`Failed to fetch bundle ${bundle.name}`); + return { name: bundle.name, data: await bundleResponse.json() }; + }) + ); + + for (const result of bundleResults) { + if (result.status === 'rejected') { + console.error(result.reason); + continue; + } + + const { name: bundleName, data: bundleData } = result.value; + newBundlePackageData[bundleName] = bundleData.packages; + + const bundlePackageIds = new Set(); + let allPackagesExist = true; + let somePackagesExist = false; + + for (const [_sectionName, pkgList] of Object.entries(bundleData.packages)) { + for (const pkg of pkgList as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + const uniqueKey = `${pkg.package}_${pkg.type}_${pkg.version || ''}_${pkg.tag || ''}`; + + if (existingPackages.has(uniqueKey)) { + bundlePackageIds.add(pkgId); + somePackagesExist = true; + } else { + allPackagesExist = false; + } + } + } + + // If all packages exist, check the bundle + if (allPackagesExist && bundlePackageIds.size > 0) { + newSelectedBundles.add(bundleName); + } else if (somePackagesExist) { + // If some packages exist, only check those packages + newSelectedPackages[bundleName] = bundlePackageIds; + } + } + + setSelectedBundles(newSelectedBundles); + setSelectedPackages(newSelectedPackages); + setBundlePackageData(newBundlePackageData); + }; + + const handleImportBundles = async () => { + if (selectedBundles.size === 0 && Object.keys(selectedPackages).length === 0) return; + setIsImporting(true); + + try { + // Get existing infrastructure packages to prevent duplicates + const existingPackages = new Map(); + if (catalogRoot?.Catalog?.InfrastructurePackages) { + for (const [_pkgId, pkg] of Object.entries(catalogRoot.Catalog.InfrastructurePackages)) { + // Create a unique key based on Name, Type, Version, and Tag + const key = `${pkg.Name}_${pkg.Type}_${pkg.Version || ''}_${pkg.Tag || ''}`; + existingPackages.set(key, pkg); + } + } + + const updatedPackages = { ...(catalogRoot?.Catalog?.InfrastructurePackages || {}) }; + + // Fetch all missing bundle data in parallel + const bundleNamesToFetch = new Set([...selectedBundles, ...Object.keys(selectedPackages)]); + const missingBundleNames = [...bundleNamesToFetch].filter((name) => !bundlePackageData[name]); + + const fetchResults = await Promise.allSettled( + missingBundleNames.map(async (name) => { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${name}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!response.ok) throw new Error(`Failed to fetch bundle ${name}`); + return { name, data: await response.json() }; + }) + ); + + const fetchedBundleData = { ...bundlePackageData }; + for (const result of fetchResults) { + if (result.status === 'fulfilled') { + fetchedBundleData[result.value.name] = result.value.data.packages; + } else { + console.error(result.reason); + } + } + setBundlePackageData(fetchedBundleData); + + const payloads: any[] = []; + const addPackage = (pkg: any) => { + const uniqueKey = `${pkg.package}_${pkg.type}_${pkg.version || ''}_${pkg.tag || ''}`; + // Skip if package already exists in catalog or has been queued in this import + if (existingPackages.has(uniqueKey)) return; + existingPackages.set(uniqueKey, pkg.package); + + let sources: any[] | undefined; + if (pkg.repo_name) { + sources = [{ Architecture: arch, RepoName: pkg.repo_name }]; + } else if (pkg.url) { + sources = [{ Architecture: arch, Uri: pkg.url }]; + } + + payloads.push({ + Name: pkg.package, + Type: pkg.type as PackageTypeValue, + Architecture: [arch], + SupportedFunctions: [{ Name: 'csi' }], + Sources: sources, + Version: pkg.version || undefined, + Tag: pkg.tag || undefined, + }); + }; + + // Queue packages from whole selected bundles + for (const bundleName of selectedBundles) { + const packages = fetchedBundleData[bundleName]; + if (!packages) continue; + for (const pkgList of Object.values(packages)) { + for (const pkg of pkgList as any[]) addPackage(pkg); + } + } + + // Queue individually selected packages + for (const [bundleName, bundleSelectedPackages] of Object.entries(selectedPackages)) { + // Skip if this bundle is already selected as a whole + if (selectedBundles.has(bundleName)) continue; + const packages = fetchedBundleData[bundleName]; + if (!packages) continue; + for (const pkgList of Object.values(packages)) { + for (const pkg of pkgList as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + if (bundleSelectedPackages.has(pkgId)) addPackage(pkg); + } + } + } + + // Import all packages in parallel + const importResults = await Promise.allSettled( + payloads.map((payload) => + addPkg.mutateAsync(payload).then((result: any) => { + const packageId = result?.package_id ?? payload.Name; + updatedPackages[packageId] = payload; + }) + ) + ); + + importResults.forEach((result) => { + if (result.status === 'rejected') { + console.error('Failed to add package:', result.reason); + } + }); + + // Update local catalogRoot with the new packages + if (catalogRoot) { + setCatalogRoot({ + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + InfrastructurePackages: updatedPackages + } + }); + } + } catch (err) { + console.error('Failed to import bundles:', err); + } finally { + setShowBundleSelector(false); + setSelectedBundles(new Set()); + setSelectedPackages({}); + setExpandedBundles(new Set()); + setIsImporting(false); + } + }; + + const handleAdd = async (data: InfrastructurePackage) => { + if (isDuplicateName(data.Name)) { + addMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + await addPkg.mutateAsync(payload); + if (catalogRoot && catalogRoot.Catalog.InfrastructurePackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + InfrastructurePackages: { + ...catalogRoot.Catalog.InfrastructurePackages, + [payload.Name]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to add infrastructure package to backend:', err); + showAlert('Failed to add infrastructure package to backend'); + } + + addMethods.reset(defaultPackage); + setShowAddForm(false); + }; + + const handleDelete = async (id: string) => { + showConfirm( + 'Delete Infrastructure Package', + `Delete infrastructure package "${id}"?`, + async () => { + try { + await deletePkg.mutateAsync(id); + } catch (err) { + console.error('Failed to delete infrastructure package from backend, removing from local state:', err); + } + // Update local catalogRoot to reflect deletion regardless of backend response + if (catalogRoot && catalogRoot.Catalog.InfrastructurePackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + InfrastructurePackages: Object.fromEntries( + Object.entries(catalogRoot.Catalog.InfrastructurePackages).filter(([key]) => key !== id) + ) + } + }; + setCatalogRoot(updatedCatalog); + } + } + ); + }; + + const handleEditPackage = (id: string, pkg: InfrastructurePackage) => { + setEditingId(id); + editMethods.reset(pkg); + setShowAddForm(false); // Close add form if open + }; + + const handleSaveEdit = async (data: InfrastructurePackage) => { + if (!editingId) return; + if (isDuplicateName(data.Name, editingId)) { + editMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + try { + await updatePkg.mutateAsync({ packageId: editingId, pkg: payload }); + } catch (updateErr) { + if ((updateErr as any).status === 404) { + await addPkg.mutateAsync(payload); + } else { + throw updateErr; + } + } + setEditingId(null); + editMethods.reset(defaultPackage); + if (catalogRoot) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + InfrastructurePackages: { + ...(catalogRoot.Catalog.InfrastructurePackages || {}), + [editingId]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to update infrastructure package in backend:', err); + showAlert('Failed to update infrastructure package in backend'); + } + }; + + const handleCancelEdit = () => { + setEditingId(null); + editMethods.reset(defaultPackage); + }; + + const extractPackageIdNumber = (packageId: string): string => { + return packageId; + }; + + if (!catalogRoot) return

No catalog loaded

; + const inner = catalogRoot.Catalog; + const packages = Object.entries( + inner.InfrastructurePackages, + ); + + return ( +
+
+

Infrastructure Packages

+
+ + +
+
+ + {showBundleSelector && ( +
+

Import from Config Files

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+
+ )} + + {showAddForm && ( + + setShowAddForm(false)} + submitLabel={addPkg.isPending ? 'Adding…' : 'Add'} + title="Add Infrastructure Package" + variant="infrastructure" + /> + + )} + + {editingId && ( +
+ + + +
+ )} + +
+ + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
Package IDNameTypeFunctionsActions
{extractPackageIdNumber(id)}{pkg.Name}{pkg.Type} + {pkg.SupportedFunctions?.map( + (f) => f.Name, + ).join(', ') || '-'} + + + +
+
+
+ ); +}; + +export default InfrastructureEditor; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx new file mode 100644 index 0000000000..1e1730df7e --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx @@ -0,0 +1,266 @@ +import React, { useState, useRef } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCatalogStore } from '../catalogStore'; +import { useAddMiscellaneousPackage, useDeleteMiscellaneousPackage, useUpdateMiscellaneousPackage } from '../hooks/useCatalog'; +import { + MiscellaneousPackageSchema, + type PackageTypeValue, + type MiscellaneousPackage, + type FunctionalPackage, +} from '../schemas/catalogSchema'; +import PackageForm from './PackageForm'; +import { showConfirm } from '../../confirmDialog/confirmDialogStore'; +import { showAlert } from '../../toast/toastStore'; + +const defaultPackage: MiscellaneousPackage = { + Name: '', + Type: 'rpm' as PackageTypeValue, + Architecture: ['x86_64'], + SupportedOS: [{ Name: 'RHEL', Version: '10.0' }], + Sources: [], + Version: '', + Tag: '', +}; + +const MiscellaneousEditor: React.FC = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + const addPackage = useAddMiscellaneousPackage(); + const deletePackage = useDeleteMiscellaneousPackage(); + const updatePackage = useUpdateMiscellaneousPackage(); + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const editFormRef = useRef(null); + + const addMethods = useForm({ + resolver: zodResolver(MiscellaneousPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const editMethods = useForm({ + resolver: zodResolver(MiscellaneousPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const isDuplicateName = (name: string, excludeId?: string | null) => { + if (!catalogRoot?.Catalog?.FunctionalPackages) return false; + return Object.entries(catalogRoot.Catalog.FunctionalPackages).some( + ([id, pkg]) => pkg.Name === name && id !== excludeId + ); + }; + + const handleAddPackage = async (data: MiscellaneousPackage) => { + if (isDuplicateName(data.Name)) { + addMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + const result = await addPackage.mutateAsync(payload as FunctionalPackage); + + addMethods.reset(defaultPackage); + setShowAddForm(false); + // Update local catalogRoot to reflect addition + const packageId = result.package_id || payload.Name; + const packageData = result.package || payload; + if (catalogRoot) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + Miscellaneous: [...(catalogRoot.Catalog.Miscellaneous || []), packageId], + FunctionalPackages: { + ...(catalogRoot.Catalog.FunctionalPackages || {}), + [packageId]: packageData + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to add miscellaneous package to backend:', err); + showAlert('Failed to add miscellaneous package to backend'); + } + }; + + const handleDeletePackage = async (packageId: string) => { + showConfirm( + 'Delete Miscellaneous Package', + `Delete miscellaneous package "${packageId}"? This cannot be undone.`, + async () => { + try { + await deletePackage.mutateAsync(packageId); + } catch (err) { + // If backend returns 404, the package might not exist in backend + // but we should still remove it from local state + console.error('Failed to delete miscellaneous package from backend, removing from local state:', err); + } + // Update local catalogRoot to reflect deletion regardless of backend response + if (catalogRoot) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + Miscellaneous: (catalogRoot.Catalog.Miscellaneous || []).filter(id => id !== packageId), + FunctionalPackages: Object.fromEntries( + Object.entries(catalogRoot.Catalog.FunctionalPackages || {}).filter(([key]) => key !== packageId) + ) + } + }; + setCatalogRoot(updatedCatalog); + } + } + ); + }; + + const handleEditPackage = (packageId: string, pkg: FunctionalPackage) => { + setEditingId(packageId); + editMethods.reset(pkg as MiscellaneousPackage); + setShowAddForm(false); // Close add form if open + }; + + const handleSaveEdit = async (data: MiscellaneousPackage) => { + if (!editingId) return; + if (isDuplicateName(data.Name, editingId)) { + editMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + const result = await updatePackage.mutateAsync({ packageId: editingId, pkg: payload as FunctionalPackage }); + setEditingId(null); + editMethods.reset(defaultPackage); + // Update local catalogRoot to reflect update + if (catalogRoot) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + Miscellaneous: (catalogRoot.Catalog.Miscellaneous || []).map(id => id === editingId ? payload.Name : id), + FunctionalPackages: { + ...(catalogRoot.Catalog.FunctionalPackages || {}), + [editingId]: result || payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to update miscellaneous package in backend:', err); + showAlert('Failed to update miscellaneous package in backend'); + } + }; + + const handleCancelEdit = () => { + setEditingId(null); + editMethods.reset(defaultPackage); + }; + + if (!catalogRoot) return

No catalog loaded

; + const inner = catalogRoot.Catalog; + const miscellaneousPackages = inner.Miscellaneous.map(id => ({ + id, + pkg: inner.FunctionalPackages[id], + })).filter(item => item.pkg !== undefined); + + return ( +
+
+

Miscellaneous Packages

+ +
+ + {showAddForm && ( + + setShowAddForm(false)} + submitLabel="Add Package" + title="Add New Miscellaneous Package" + /> + + )} + + {editingId && ( +
+ + + +
+ )} + +
+ + + + + + + + + + + + {miscellaneousPackages.length === 0 ? ( + + + + ) : ( + miscellaneousPackages.map(({ id, pkg }) => ( + + + + + + + + )) + )} + +
Package IDNameTypeArchActions
+ No miscellaneous packages defined +
{id}{pkg.Name}{pkg.Type}{pkg.Architecture.join(', ')} + + +
+
+
+ ); +}; + +export default MiscellaneousEditor; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx b/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx new file mode 100644 index 0000000000..ad140f175d --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx @@ -0,0 +1,761 @@ +import { useState, useEffect, useRef } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCatalogStore } from '../catalogStore'; +import { useAddOSPackage, useDeleteOSPackage, useUpdateOSPackage } from '../hooks/useCatalog'; +import { + OSPackageSchema, + type PackageTypeValue, + type OSPackage, + type FunctionalPackage, +} from '../schemas/catalogSchema'; +import BundleSelector from './BundleSelector'; +import PackageForm from './PackageForm'; +import { showConfirm } from '../../confirmDialog/confirmDialogStore'; +import { showAlert } from '../../toast/toastStore'; + +const defaultPackage: OSPackage = { + Name: '', + Type: 'rpm' as PackageTypeValue, + Architecture: ['x86_64'], + SupportedOS: [{ Name: 'RHEL', Version: '10.0' }], + Sources: [], + Version: '', + Tag: '', +}; + +const OSPackageEditor = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const setCatalogRoot = useCatalogStore((s) => s.setCatalogRoot); + const addOSPackage = useAddOSPackage(); + const deleteOSPackage = useDeleteOSPackage(); + const updateOSPackage = useUpdateOSPackage(); + const [showAddForm, setShowAddForm] = useState(false); + const [showBundleSelector, setShowBundleSelector] = useState(false); + const [editingId, setEditingId] = useState(null); + const editFormRef = useRef(null); + + const addMethods = useForm({ + resolver: zodResolver(OSPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const editMethods = useForm({ + resolver: zodResolver(OSPackageSchema), + defaultValues: defaultPackage, + mode: 'onSubmit', + }); + + const isDuplicateName = (name: string, excludeId?: string | null) => { + if (!catalogRoot?.Catalog?.OSPackages) return false; + return Object.entries(catalogRoot.Catalog.OSPackages).some( + ([id, pkg]) => pkg.Name === name && id !== excludeId + ); + }; + const [osFamily, setOsFamily] = useState('RHEL'); + const [osVersion, setOsVersion] = useState('10.0'); + const [arch, setArch] = useState('x86_64'); + const [selectedBundles, setSelectedBundles] = useState>(new Set(['default_packages'])); + const [expandedBundles, setExpandedBundles] = useState>(new Set()); + const [selectedPackages, setSelectedPackages] = useState>>({}); + const [bundlePackageData, setBundlePackageData] = useState>>({}); + const [isImporting, setIsImporting] = useState(false); + const prevOsPackageKeysRef = useRef(''); + + // Automatically derive BaseOS from OSPackages + useEffect(() => { + if (!catalogRoot?.Catalog?.OSPackages) return; + + const osPackageIds = Object.keys(catalogRoot.Catalog.OSPackages).sort(); + const keysString = osPackageIds.join(','); + + if (keysString === prevOsPackageKeysRef.current) return; + prevOsPackageKeysRef.current = keysString; + + // Extract OS family and version from first package (or use defaults) + let derivedOsFamily = 'RHEL'; + let derivedOsVersion = '10.0'; + + if (osPackageIds.length > 0) { + const firstPkg = catalogRoot.Catalog.OSPackages[osPackageIds[0]]; + if (firstPkg?.SupportedOS && firstPkg.SupportedOS.length > 0) { + derivedOsFamily = firstPkg.SupportedOS[0].Name; + derivedOsVersion = firstPkg.SupportedOS[0].Version; + } + } + + // Update BaseOS to match OSPackages + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + BaseOS: [{ + Name: derivedOsFamily, + Version: derivedOsVersion, + osPackages: osPackageIds + }] + } + }; + + setCatalogRoot(updatedCatalog); + }, [catalogRoot?.Catalog?.OSPackages, setCatalogRoot]); + + const bundleMetadata: Record = { + default_packages: { + required: true, + description: 'Core OS packages required for basic functionality' + }, + admin_debug_packages: { + required: false, + description: 'Admin and debugging tools (vim, gcc, gdb, etc.)' + }, + openldap: { + required: false, + description: 'LDAP authentication packages' + }, + openmpi: { + required: false, + description: 'MPI implementation for parallel computing' + }, + ucx: { + required: false, + description: 'UCX communication library for HPC' + }, + ldms: { + required: false, + description: 'LDMS monitoring system' + }, + nfs: { + required: false, + description: 'Network File System packages' + } + }; + + const handleBundleToggle = async (bundleName: string, required: boolean) => { + if (required) return; + const newSelected = new Set(selectedBundles); + + if (newSelected.has(bundleName)) { + // Deselect bundle and clear individual package selections + newSelected.delete(bundleName); + setSelectedPackages(prev => { + const updated = { ...prev }; + delete updated[bundleName]; + return updated; + }); + } else { + // Select bundle and select all packages + newSelected.add(bundleName); + + // Fetch package data if not already loaded + if (!bundlePackageData[bundleName]) { + try { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundleName}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (response.ok) { + const data = await response.json(); + setBundlePackageData(prev => ({ + ...prev, + [bundleName]: data.packages + })); + + // Select all packages from the fetched data + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(data.packages)) { + for (const pkg of packages as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } catch (error) { + console.error('Failed to fetch bundle packages:', error); + } + } else { + // Select all packages from already loaded data + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(bundlePackageData[bundleName])) { + for (const pkg of packages) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } + setSelectedBundles(newSelected); + }; + + const handleBundleExpand = async (bundleName: string) => { + const newExpanded = new Set(expandedBundles); + if (newExpanded.has(bundleName)) { + newExpanded.delete(bundleName); + setExpandedBundles(newExpanded); + } else { + newExpanded.add(bundleName); + setExpandedBundles(newExpanded); + + // Fetch package data for this bundle if not already loaded + if (!bundlePackageData[bundleName]) { + try { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundleName}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (response.ok) { + const data = await response.json(); + setBundlePackageData(prev => ({ + ...prev, + [bundleName]: data.packages + })); + + // If this is a required bundle, select all its packages + if (bundleMetadata[bundleName]?.required) { + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(data.packages)) { + for (const pkg of packages as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } + } catch (error) { + console.error('Failed to fetch bundle packages:', error); + } + } else if (bundleMetadata[bundleName]?.required) { + // If data is already loaded and bundle is required, ensure all packages are selected + const allPackageIds = new Set(); + for (const [_section, packages] of Object.entries(bundlePackageData[bundleName])) { + for (const pkg of packages) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + setSelectedPackages(prev => ({ + ...prev, + [bundleName]: allPackageIds + })); + } + } + }; + + const handlePackageToggle = (bundleName: string, packageId: string) => { + // Prevent unchecking packages in required bundles + if (bundleMetadata[bundleName]?.required) { + return; + } + setSelectedPackages(prev => { + const bundlePackages = prev[bundleName] || new Set(); + const newBundlePackages = new Set(bundlePackages); + if (newBundlePackages.has(packageId)) { + newBundlePackages.delete(packageId); + } else { + newBundlePackages.add(packageId); + } + return { ...prev, [bundleName]: newBundlePackages }; + }); + }; + + const handleOpenBundleSelector = async () => { + setShowBundleSelector(true); + + // Get existing OS packages to initialize selection state + const existingPackages = new Map(); + if (catalogRoot?.Catalog?.OSPackages) { + for (const [_pkgId, pkg] of Object.entries(catalogRoot.Catalog.OSPackages)) { + const key = `${pkg.Name}_${pkg.Type}_${pkg.Version || ''}_${pkg.Tag || ''}`; + existingPackages.set(key, pkg); + } + } + + // Fetch all available bundles + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundles?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!response.ok) return; + + const data = await response.json(); + const bundles = data.bundles as { name: string }[]; + + // Initialize selection state based on existing packages + const newSelectedBundles = new Set(); + const newSelectedPackages: Record> = {}; + const newBundlePackageData: Record> = {}; + + const bundleResults = await Promise.allSettled( + bundles.map(async (bundle) => { + const bundleResponse = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${bundle.name}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!bundleResponse.ok) throw new Error(`Failed to fetch bundle ${bundle.name}`); + return { name: bundle.name, data: await bundleResponse.json() }; + }) + ); + + for (const result of bundleResults) { + if (result.status === 'rejected') { + console.error(result.reason); + continue; + } + + const { name: bundleName, data: bundleData } = result.value; + newBundlePackageData[bundleName] = bundleData.packages; + + const bundlePackageIds = new Set(); + let allPackagesExist = true; + let somePackagesExist = false; + + for (const [_sectionName, pkgList] of Object.entries(bundleData.packages)) { + for (const pkg of pkgList as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + const uniqueKey = `${pkg.package}_${pkg.type}_${pkg.version || ''}_${pkg.tag || ''}`; + + if (existingPackages.has(uniqueKey)) { + bundlePackageIds.add(pkgId); + somePackagesExist = true; + } else { + allPackagesExist = false; + } + } + } + + // If all packages exist, check the bundle + if (allPackagesExist && bundlePackageIds.size > 0) { + newSelectedBundles.add(bundleName); + } else if (somePackagesExist) { + // If some packages exist, only check those packages + newSelectedPackages[bundleName] = bundlePackageIds; + } + + // Always include default_packages and select all its packages + if (bundleName === 'default_packages') { + newSelectedBundles.add('default_packages'); + // Select all packages in default_packages + const allPackageIds = new Set(); + for (const [_sectionName, pkgList] of Object.entries(bundleData.packages)) { + for (const pkg of pkgList as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + allPackageIds.add(pkgId); + } + } + newSelectedPackages['default_packages'] = allPackageIds; + } + } + + setSelectedBundles(newSelectedBundles); + setSelectedPackages(newSelectedPackages); + setBundlePackageData(newBundlePackageData); + }; + + const handleImportBundles = async () => { + if (selectedBundles.size === 0 && Object.keys(selectedPackages).length === 0) return; + setIsImporting(true); + + try { + // Get existing OS packages to prevent duplicates + const existingPackages = new Map(); + if (catalogRoot?.Catalog?.OSPackages) { + for (const [pkgId, pkg] of Object.entries(catalogRoot.Catalog.OSPackages)) { + // Create a unique key based on Name, Type, Version, and Tag + const key = `${pkg.Name}_${pkg.Type}_${pkg.Version || ''}_${pkg.Tag || ''}`; + existingPackages.set(key, pkgId); + } + } + + const updatedPackages = { ...(catalogRoot?.Catalog?.OSPackages || {}) }; + + // Fetch all missing bundle data in parallel + const bundleNamesToFetch = new Set([...selectedBundles, ...Object.keys(selectedPackages)]); + const missingBundleNames = [...bundleNamesToFetch].filter((name) => !bundlePackageData[name]); + + const fetchResults = await Promise.allSettled( + missingBundleNames.map(async (name) => { + const response = await fetch( + `/api/v1/catalog-editor/os-packages/bundle/${name}?arch=${arch}&os_family=${osFamily}&version=${osVersion}` + ); + if (!response.ok) throw new Error(`Failed to fetch bundle ${name}`); + const data = await response.json(); + return { name, packages: data.packages }; + }) + ); + + const fetchedBundleData = { ...bundlePackageData }; + for (const result of fetchResults) { + if (result.status === 'fulfilled') { + fetchedBundleData[result.value.name] = result.value.packages; + } else { + console.error(result.reason); + } + } + setBundlePackageData(fetchedBundleData); + + const payloads: any[] = []; + const addPackage = (pkg: any) => { + const uniqueKey = `${pkg.package}_${pkg.type}_${pkg.version || ''}_${pkg.tag || ''}`; + // Skip if package already exists in catalog or has been queued in this import + if (existingPackages.has(uniqueKey)) return; + existingPackages.set(uniqueKey, pkg.package); + + let sources: any[] | undefined; + if (pkg.repo_name) { + sources = [{ Architecture: arch, RepoName: pkg.repo_name }]; + } else if (pkg.url) { + sources = [{ Architecture: arch, Uri: pkg.url }]; + } + + payloads.push({ + Name: pkg.package, + Type: pkg.type as PackageTypeValue, + Architecture: [arch], + SupportedOS: [{ Name: osFamily, Version: osVersion }], + Sources: sources, + Version: pkg.version || undefined, + Tag: pkg.tag || undefined, + }); + }; + + // Queue packages from whole selected bundles + for (const bundleName of selectedBundles) { + const packages = fetchedBundleData[bundleName]; + if (!packages) continue; + for (const pkgList of Object.values(packages)) { + for (const pkg of pkgList as any[]) addPackage(pkg); + } + } + + // Queue individually selected packages + for (const [bundleName, bundleSelectedPackages] of Object.entries(selectedPackages)) { + // Skip if this bundle is already selected as a whole + if (selectedBundles.has(bundleName)) continue; + const packages = fetchedBundleData[bundleName]; + if (!packages) continue; + for (const pkgList of Object.values(packages)) { + for (const pkg of pkgList as any[]) { + const pkgId = `${pkg.package}_${pkg.type}`; + if (bundleSelectedPackages.has(pkgId)) addPackage(pkg); + } + } + } + + // Import all packages in parallel + const importResults = await Promise.allSettled( + payloads.map((payload) => + addOSPackage.mutateAsync(payload).then((result: any) => { + const packageId = result?.package_id ?? payload.Name; + updatedPackages[packageId] = payload; + }) + ) + ); + + importResults.forEach((result) => { + if (result.status === 'rejected') { + console.error('Failed to add package:', result.reason); + } + }); + + // Update local catalogRoot with the new packages + if (catalogRoot) { + setCatalogRoot({ + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + OSPackages: updatedPackages + } + }); + } + } catch (err) { + console.error('Failed to import bundles:', err); + } finally { + setShowBundleSelector(false); + setSelectedBundles(new Set(['default_packages'])); + setSelectedPackages({}); + setExpandedBundles(new Set()); + setIsImporting(false); + } + }; + + const handleAddPackage = async (data: OSPackage) => { + if (isDuplicateName(data.Name)) { + addMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + await addOSPackage.mutateAsync(payload as FunctionalPackage); + if (catalogRoot && catalogRoot.Catalog.OSPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + OSPackages: { + ...catalogRoot.Catalog.OSPackages, + [payload.Name]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to add OS package to backend:', err); + showAlert('Failed to add OS package to backend'); + } + + addMethods.reset(defaultPackage); + setShowAddForm(false); + }; + + const handleDeletePackage = async (id: string) => { + showConfirm( + 'Delete OS Package', + `Delete OS package "${id}"?`, + async () => { + try { + await deleteOSPackage.mutateAsync(id); + } catch (err) { + console.error('Failed to delete OS package from backend, removing from local state:', err); + } + if (catalogRoot && catalogRoot.Catalog.OSPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + OSPackages: Object.fromEntries( + Object.entries(catalogRoot.Catalog.OSPackages).filter(([key]) => key !== id) + ) + } + }; + setCatalogRoot(updatedCatalog); + } + } + ); + }; + + const handleEditPackage = (id: string, pkg: FunctionalPackage) => { + setEditingId(id); + editMethods.reset(pkg as OSPackage); + setShowAddForm(false); + }; + + const handleSaveEdit = async (data: OSPackage) => { + if (!editingId) return; + if (isDuplicateName(data.Name, editingId)) { + editMethods.setError('Name', { message: 'A package with this name already exists' }); + return; + } + + const payload = { ...data }; + if (!payload.Version?.trim()) delete payload.Version; + if (!payload.Tag?.trim()) delete payload.Tag; + + try { + await updateOSPackage.mutateAsync({ packageId: editingId, pkg: payload as FunctionalPackage }); + setEditingId(null); + editMethods.reset(defaultPackage); + if (catalogRoot && catalogRoot.Catalog.OSPackages) { + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + OSPackages: { + ...catalogRoot.Catalog.OSPackages, + [editingId]: payload + } + } + }; + setCatalogRoot(updatedCatalog); + } + } catch (err) { + console.error('Failed to update OS package in backend:', err); + showAlert('Failed to update OS package in backend'); + } + }; + + const handleCancelEdit = () => { + setEditingId(null); + editMethods.reset(defaultPackage); + }; + + const extractPackageIdNumber = (packageId: string): string => { + return packageId; + }; + + if (!catalogRoot) return

No catalog loaded

; + const inner = catalogRoot.Catalog; + const packages = Object.entries(inner.OSPackages); + + return ( +
+
+

OS Packages

+
+ + +
+
+ + {showBundleSelector && ( +
+

Import from Config Files

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+
+ )} + + {showAddForm && ( + + setShowAddForm(false)} + submitLabel={addOSPackage.isPending ? 'Adding…' : 'Add'} + title="Add New OS Package" + /> + + )} + + {editingId && ( +
+ + + +
+ )} + +
+ + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
Package IDNameTypeArchActions
{extractPackageIdNumber(id)}{pkg.Name}{pkg.Type}{pkg.Architecture.join(', ')} + + +
+
+
+ ); +}; + +export default OSPackageEditor; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx b/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx new file mode 100644 index 0000000000..6fe9b100bd --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx @@ -0,0 +1,335 @@ +import React from 'react'; +import { useFormContext, Controller, useWatch } from 'react-hook-form'; +import { useFormErrors } from '../../configuration-wizard/hooks/useFormErrors'; + +interface PackageFormProps { + onSubmit: (data: any) => void; + onCancel: () => void; + submitLabel: string; + title: string; + variant?: 'package' | 'infrastructure' | 'driver'; +} + +const PACKAGE_TYPES = [ + 'rpm', + 'rpm_repo', + 'tarball', + 'iso', + 'git', + 'image', + 'pip_module', + 'manifest', +]; + +const ARCHITECTURES = ['x86_64', 'aarch64']; + +const PackageForm: React.FC = ({ + onSubmit, + onCancel, + submitLabel, + title, + variant = 'package', +}) => { + const { + register, + control, + handleSubmit, + setValue, + getValues, + formState, + } = useFormContext(); + const getError = useFormErrors(formState.errors); + + const nameError = getError('Name'); + const typeError = getError('Type'); + const architectureError = getError('Architecture'); + const versionError = getError('Version'); + const tagError = getError('Tag'); + const uriError = getError('Uri'); + const supportedOSNameError = getError('SupportedOS.0.Name'); + const supportedOSVersionError = getError('SupportedOS.0.Version'); + const supportedFunctionsError = getError('SupportedFunctions.0.Name'); + const driverBrandError = getError('Config.DriverBrand'); + const driverTypeError = getError('Config.DriverType'); + + const architecture = (useWatch({ control, name: 'Architecture' }) as string[]) || []; + const sources = (useWatch({ control, name: 'Sources' }) as any[]) || []; + + const handleArchitectureChange = (current: string[], arch: string) => (e: React.ChangeEvent) => { + const updated = e.target.checked + ? [...current, arch] + : current.filter((a) => a !== arch); + + setValue('Architecture', updated, { shouldValidate: false }); + + if (variant === 'driver') return; + + const newSources = updated.map((a) => { + const existing = sources.find((s) => s.Architecture === a); + return existing || { Architecture: a, RepoName: '', Uri: '' }; + }); + setValue('Sources', newSources, { shouldValidate: false }); + }; + + const handleSourceChange = (arch: string, field: 'RepoName' | 'Uri') => (e: React.ChangeEvent) => { + const value = e.target.value; + const currentSources = [...((getValues('Sources') as any[]) || [])]; + const idx = currentSources.findIndex((s) => s.Architecture === arch); + + if (idx === -1) { + if (!value.trim()) return; + currentSources.push({ Architecture: arch, [field]: value }); + } else { + currentSources[idx] = { ...currentSources[idx], [field]: value }; + if ( + !currentSources[idx].RepoName?.trim() && + !currentSources[idx].Uri?.trim() + ) { + currentSources.splice(idx, 1); + } + } + + setValue('Sources', currentSources, { shouldValidate: false }); + }; + + return ( +
+

{title}

+ {formState.errors.root && ( +
+ {formState.errors.root.message as string} +
+ )} +
+
+ + + {nameError && ( + {nameError?.message} + )} +
+
+ + + {typeError && ( + {typeError?.message} + )} +
+
+ + ( +
+ {ARCHITECTURES.map((arch) => ( + + ))} +
+ )} + /> + {architectureError && ( + + {architectureError?.message} + + )} +
+ {(variant === 'driver' || variant === 'infrastructure') && ( +
+ + + {uriError && ( + + {uriError?.message} + + )} +
+ )} +
+ + + {versionError && ( + + {versionError?.message} + + )} +
+ {variant !== 'driver' && ( +
+ + + {tagError && ( + {tagError?.message} + )} +
+ )} + {variant === 'package' && ( + <> +
+ + + {supportedOSNameError && ( + + {supportedOSNameError?.message} + + )} +
+
+ + + {supportedOSVersionError && ( + + {supportedOSVersionError?.message} + + )} +
+ + )} + {variant === 'infrastructure' && ( +
+ + + {supportedFunctionsError && ( + + {supportedFunctionsError?.message} + + )} +
+ )} + {variant === 'driver' && ( + <> +
+ + + {driverBrandError && ( + + {driverBrandError?.message} + + )} +
+
+ + + {driverTypeError && ( + + {driverTypeError?.message} + + )} +
+ + )} + {variant !== 'driver' && architecture.map((arch) => { + const sourceIndex = sources.findIndex((s) => s.Architecture === arch); + const source = sources[sourceIndex] || { + Architecture: arch, + RepoName: '', + Uri: '', + }; + const uriError = sourceIndex >= 0 ? getError(`Sources.${sourceIndex}.Uri`) : undefined; + const repoNameError = sourceIndex >= 0 ? getError(`Sources.${sourceIndex}.RepoName`) : undefined; + const presenceError = uriError && !source.Uri?.trim() ? uriError : undefined; + return ( +
+

Source for {arch}

+
+
+ + + {(repoNameError || presenceError) && ( + + {(repoNameError || presenceError)?.message} + + )} +
+
+ + + {uriError && ( + + {uriError.message} + + )} +
+
+
+ ); + })} +
+
+ + +
+
+ ); +}; + +export default PackageForm; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx b/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx new file mode 100644 index 0000000000..c7fb704235 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx @@ -0,0 +1,108 @@ +interface RoleSelectorProps { + show: boolean; + osFamily: string; + osVersion: string; + arch: string; + selectedRole: string; + roles: string[] | undefined; + onOsFamilyChange: (value: string) => void; + onOsVersionChange: (value: string) => void; + onArchChange: (value: string) => void; + onRoleChange: (value: string) => void; + onAutoPopulate: () => void; + onClose: () => void; + isLoading?: boolean; +} + +export const RoleSelector = ({ + show, + osFamily, + osVersion, + arch, + selectedRole, + roles, + onOsFamilyChange, + onOsVersionChange, + onArchChange, + onRoleChange, + onAutoPopulate, + onClose, + isLoading = false, +}: RoleSelectorProps) => { + if (!show) return null; + + return ( +
+

Auto-populate Layers from Roles

+

+ Select a role to automatically populate functional layers with suggested packages. +

+
+
+ + +
+ +
+ + onOsVersionChange(e.target.value)} + className="form-input" + placeholder="e.g. 10.0" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx b/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx new file mode 100644 index 0000000000..9f8a409e59 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx @@ -0,0 +1,109 @@ +import { useCatalogStore } from '../catalogStore'; +import { useValidateCatalog } from '../hooks/useCatalog'; + +const ValidationPanel = () => { + const catalogRoot = useCatalogStore((s) => s.catalogRoot); + const validationErrors = useCatalogStore((s) => s.validationErrors); + const validationWarnings = useCatalogStore((s) => s.validationWarnings); + const setValidationResults = useCatalogStore((s) => s.setValidationResults); + const validateCatalog = useValidateCatalog(); + + const handleRevalidate = async () => { + if (!catalogRoot) return; + try { + const result = + await validateCatalog.mutateAsync(catalogRoot); + setValidationResults(result.errors, result.warnings); + } catch (err) { + console.error('Validation failed:', err); + } + }; + + // Separate L1 and L2 errors/warnings + const l1Errors = validationErrors.filter(e => !e.startsWith('[L2]')); + const l2Errors = validationErrors.filter(e => e.startsWith('[L2]')); + const l1Warnings = validationWarnings.filter(w => !w.startsWith('[L2]')); + const l2Warnings = validationWarnings.filter(w => w.startsWith('[L2]')); + + return ( +
+
+

Validation Results

+ +
+ + {validationErrors.length === 0 && + validationWarnings.length === 0 && ( +
+ [OK] Catalog schema valid +
+ )} + + {/* L1 Errors */} + {l1Errors.length > 0 && ( +
+

+ L1 Errors (Schema) ({l1Errors.length}) +

+ {l1Errors.map((error, i) => ( +
+ {error} +
+ ))} +
+ )} + + {/* L2 Errors */} + {l2Errors.length > 0 && ( +
+

+ L2 Errors (Business Logic) ({l2Errors.length}) +

+ {l2Errors.map((error, i) => ( +
+ {error.replace('[L2] ', '')} +
+ ))} +
+ )} + + {/* L1 Warnings */} + {l1Warnings.length > 0 && ( +
+

+ L1 Warnings ({l1Warnings.length}) +

+ {l1Warnings.map((warning, i) => ( +
+ {warning} +
+ ))} +
+ )} + + {/* L2 Warnings */} + {l2Warnings.length > 0 && ( +
+

+ L2 Warnings ({l2Warnings.length}) +

+ {l2Warnings.map((warning, i) => ( +
+ {warning.replace('[L2] ', '')} +
+ ))} +
+ )} +
+ ); +}; + +export default ValidationPanel; diff --git a/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts b/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts new file mode 100644 index 0000000000..8ed27d559c --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts @@ -0,0 +1,18 @@ +import type { CatalogRoot } from '../schemas/catalogSchema'; + +export const EMPTY_CATALOG: CatalogRoot = { + Catalog: { + Name: 'Catalog', + Version: '1.0', + Identifier: 'image-build', + FunctionalLayer: [], + BaseOS: [{ Name: 'RHEL', Version: '10.0', osPackages: [] }], + Infrastructure: [{ Name: 'csi', InfrastructurePackages: [] }], + Drivers: [], + DriverPackages: {}, + FunctionalPackages: {}, + OSPackages: {}, + InfrastructurePackages: {}, + Miscellaneous: [], + }, +}; diff --git a/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts b/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts new file mode 100644 index 0000000000..dac3398f4a --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; + +interface BundleInfo { + name: string; + type: string; + package_count: number; + sections: string[]; +} + +interface PackageDefinition { + package: string; + type: string; + repo_name?: string; + url?: string; + tag?: string; + version?: string; +} + +const API_BASE = '/api/v1/catalog-editor'; + +export const useAvailableBundles = ( + arch: string, + osFamily: string, + version: string +) => { + return useQuery({ + queryKey: ['bundles', arch, osFamily, version], + queryFn: async (): Promise => { + const res = await fetch( + `${API_BASE}/os-packages/bundles?arch=${arch}&os_family=${osFamily}&version=${version}` + ); + if (!res.ok) throw new Error('Failed to fetch bundles'); + const data = await res.json(); + return data.bundles; + }, + enabled: !!arch && !!osFamily && !!version, + }); +}; + +export const useBundlePackages = ( + arch: string, + osFamily: string, + version: string, + bundleName: string +) => { + return useQuery({ + queryKey: ['bundle-packages', arch, osFamily, version, bundleName], + queryFn: async (): Promise> => { + const res = await fetch( + `${API_BASE}/os-packages/bundle/${encodeURIComponent(bundleName)}?arch=${arch}&os_family=${osFamily}&version=${version}` + ); + if (!res.ok) throw new Error('Failed to fetch bundle packages'); + const data = await res.json(); + return data.packages; + }, + enabled: !!arch && !!osFamily && !!version && !!bundleName, + }); +}; + diff --git a/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts b/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts new file mode 100644 index 0000000000..b9955a73f8 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts @@ -0,0 +1,217 @@ +import { useMutation } from '@tanstack/react-query'; +import type { + CatalogRoot, + FunctionalPackage, + InfrastructurePackage, + FunctionalLayer, + DriverPackage, + OSPackage, + MiscellaneousPackage, +} from '../schemas/catalogSchema'; +import { extractErrorMessage } from '../utils/extractErrorMessage'; + +const API_BASE = '/api/v1/catalog'; + +// NOTE: Mutations in this file do NOT invalidate queries. +// The catalog editor manages local state manually via catalogStore. + +async function apiRequest( + path: string, + method: string = 'GET', + body?: unknown, +): Promise { + const options: RequestInit = { method }; + if (body) { + options.headers = { 'Content-Type': 'application/json' }; + options.body = JSON.stringify(body); + } + + const res = await fetch(`${API_BASE}${path}`, options); + if (!res.ok) { + const data = await res.json().catch(() => null); + const err = new Error(extractErrorMessage({ data })) as Error & { + status?: number; + data?: any; + }; + err.status = res.status; + err.data = data; + throw err; + } + return res.json(); +} + +// ─── VALIDATE ────────────────────────────────────────────── + +export const useValidateCatalog = () => + useMutation({ + mutationFn: async (catalog: CatalogRoot) => + apiRequest<{ + valid: boolean; + errors: string[]; + warnings: string[]; + }>('/validate', 'POST', catalog), + }); + +// ─── Functional Packages ──────────────────────────────────── + +export const useAddFunctionalPackage = () => + useMutation({ + mutationFn: (pkg: FunctionalPackage) => + apiRequest<{ + package_id: string; + package: FunctionalPackage; + }>('/packages/functional', 'POST', pkg), + }); + +export const useUpdateFunctionalPackage = () => + useMutation({ + mutationFn: ({ packageId, pkg }: { packageId: string; pkg: FunctionalPackage }) => + apiRequest( + `/packages/functional/${encodeURIComponent(packageId)}`, + 'PUT', + pkg, + ), + // Note: Unlike infrastructure/driver updates, this does not support 404-fallback to add + }); + +export const useDeleteFunctionalPackage = () => + useMutation({ + mutationFn: (packageId: string) => + apiRequest(`/packages/functional/${encodeURIComponent(packageId)}`, 'DELETE'), + }); + +// ─── OS Packages ──────────────────────────────────────────── + +export const useAddOSPackage = () => + useMutation({ + mutationFn: (pkg: OSPackage) => + apiRequest('/packages/os', 'POST', pkg), + }); + +export const useDeleteOSPackage = () => + useMutation({ + mutationFn: (packageId: string) => + apiRequest(`/packages/os/${encodeURIComponent(packageId)}`, 'DELETE'), + }); + +export const useUpdateOSPackage = () => + useMutation({ + mutationFn: ({ packageId, pkg }: { packageId: string; pkg: OSPackage }) => + apiRequest( + `/packages/os/${encodeURIComponent(packageId)}`, + 'PUT', + pkg, + ), + // Note: Unlike infrastructure/driver updates, this does not support 404-fallback to add + }); + +// ─── Infrastructure Packages ──────────────────────────────── + +export const useAddInfrastructurePackage = () => + useMutation({ + mutationFn: (pkg: InfrastructurePackage) => + apiRequest('/packages/infrastructure', 'POST', pkg), + }); + +export const useDeleteInfrastructurePackage = () => + useMutation({ + mutationFn: (packageId: string) => + apiRequest(`/packages/infrastructure/${encodeURIComponent(packageId)}`, 'DELETE'), + }); + +export const useUpdateInfrastructurePackage = () => + useMutation({ + mutationFn: ({ packageId, pkg }: { packageId: string; pkg: InfrastructurePackage }) => + apiRequest( + `/packages/infrastructure/${encodeURIComponent(packageId)}`, + 'PUT', + pkg, + ), + // Supports 404-fallback: editors can check err.status === 404 to fall back to add operation + }); + +// ─── Functional Layers ────────────────────────────────────── + +export const useAddFunctionalLayer = () => + useMutation({ + mutationFn: (layer: FunctionalLayer) => + apiRequest('/layers', 'POST', layer), + }); + +export const useUpdateFunctionalLayer = () => + useMutation({ + mutationFn: ({ layerName, layer }: { layerName: string; layer: FunctionalLayer }) => + apiRequest( + `/layers/${encodeURIComponent(layerName)}`, + 'PUT', + layer, + ), + // Note: Unlike infrastructure/driver updates, this does not support 404-fallback to add + }); + +export const useDeleteFunctionalLayer = () => + useMutation({ + mutationFn: (layerName: string) => + apiRequest(`/layers/${encodeURIComponent(layerName)}`, 'DELETE'), + }); + +// ─── Miscellaneous Packages ───────────────────────────────── + +export const useAddMiscellaneousPackage = () => + useMutation({ + mutationFn: (pkg: MiscellaneousPackage) => + apiRequest<{ + package_id: string; + package: MiscellaneousPackage; + }>('/packages/miscellaneous', 'POST', pkg), + }); + +export const useUpdateMiscellaneousPackage = () => + useMutation({ + mutationFn: ({ packageId, pkg }: { packageId: string; pkg: MiscellaneousPackage }) => + apiRequest( + `/packages/miscellaneous/${encodeURIComponent(packageId)}`, + 'PUT', + pkg, + ), + // Note: Unlike infrastructure/driver updates, this does not support 404-fallback to add + }); + +export const useDeleteMiscellaneousPackage = () => + useMutation({ + mutationFn: (packageId: string) => + apiRequest(`/packages/miscellaneous/${encodeURIComponent(packageId)}`, 'DELETE'), + }); + +// ─── Driver Packages ──────────────────────────────────────── + +export const useAddDriverPackage = () => + useMutation({ + mutationFn: (pkg: DriverPackage) => + apiRequest('/packages/driver', 'POST', pkg), + }); + +export const useDeleteDriverPackage = () => + useMutation({ + mutationFn: (packageId: string) => + apiRequest(`/packages/driver/${encodeURIComponent(packageId)}`, 'DELETE'), + }); + +export const useUpdateDriverPackage = () => + useMutation({ + mutationFn: ({ packageId, pkg }: { packageId: string; pkg: DriverPackage }) => + apiRequest( + `/packages/driver/${encodeURIComponent(packageId)}`, + 'PUT', + pkg, + ), + // Supports 404-fallback: editors can check err.status === 404 to fall back to add operation + }); + +// ─── Import / Export ──────────────────────────────────────── + +export const useImportCatalog = () => + useMutation({ + mutationFn: (catalog: CatalogRoot) => + apiRequest('/import', 'POST', catalog), + }); diff --git a/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts b/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts new file mode 100644 index 0000000000..33a144aae7 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +const API_BASE = '/api/v1/catalog-editor'; + +export const useAvailableRoles = () => { + return useQuery({ + queryKey: ['roles'], + queryFn: async (): Promise => { + const res = await fetch(`${API_BASE}/roles`); + if (!res.ok) throw new Error('Failed to fetch roles'); + const data = await res.json(); + return data.roles; + }, + }); +}; + +export const useRolePackages = ( + role: string, + arch: string, + osFamily: string, + version: string +) => { + return useQuery({ + queryKey: ['role-packages', role, arch, osFamily, version], + queryFn: async (): Promise> => { + const res = await fetch( + `${API_BASE}/roles/${encodeURIComponent(role)}/packages?arch=${arch}&os_family=${osFamily}&version=${version}` + ); + if (!res.ok) throw new Error('Failed to fetch role packages'); + const data = await res.json(); + return data.packages; + }, + enabled: !!role && !!arch && !!osFamily && !!version, + }); +}; diff --git a/utils/gui/frontend/src/features/catalog-editor/index.ts b/utils/gui/frontend/src/features/catalog-editor/index.ts new file mode 100644 index 0000000000..56c9b45c07 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/index.ts @@ -0,0 +1,2 @@ +export { default as CatalogEditor } from './CatalogEditor' +export { useCatalogStore } from './catalogStore' diff --git a/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts b/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts new file mode 100644 index 0000000000..5308088808 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts @@ -0,0 +1,198 @@ +import { z } from 'zod'; +import { URL_PATTERN } from '../../configuration-wizard/schemas/common'; + +// ─── Enums ───────────────────────────────────────────────── + +export const PackageType = z.enum([ + 'rpm', + 'rpm_repo', + 'tarball', + 'iso', + 'git', + 'image', + 'pip_module', + 'manifest', +]); + +// ─── Sub-schemas ─────────────────────────────────────────── + +export const SupportedOSSchema = z.object({ + Name: z.string().min(1, 'OS Name is required'), + Version: z.string().min(1, 'OS Version is required'), +}); + +const sourceBaseSchema = z.object({ + Architecture: z.string().min(1, 'Architecture is required'), + RepoName: z.string().optional(), + Uri: z.string().optional(), +}); + +const sourcePresenceRefine = (data: { + RepoName?: string; + Uri?: string; +}): boolean => Boolean(data.RepoName?.trim() || data.Uri?.trim()); + +const sourceUrlRefine = (data: { + Uri?: string; +}): boolean => { + if (!data.Uri?.trim()) return true; + return URL_PATTERN.test(data.Uri.trim()); +}; + +export const PackageSourceSchema = sourceBaseSchema + .refine(sourceUrlRefine, { + message: 'Uri must be a valid URL starting with http:// or https://', + path: ['Uri'], + }) + .refine(sourcePresenceRefine, { + message: 'Either RepoName or Uri is required', + path: ['Uri'], + }); + +export const PackageSourceWithUrlSchema = PackageSourceSchema; + +// ─── Package schemas ─────────────────────────────────────── + +export const FunctionalPackageSchema = z.object({ + Name: z.string().min(1, 'Name is required'), + Type: PackageType, + Architecture: z + .array(z.string().min(1)) + .min(1, 'At least one architecture is required'), + SupportedOS: z + .array(SupportedOSSchema) + .min(1, 'At least one supported OS is required'), + Sources: z.array(PackageSourceSchema).optional(), + Version: z.string().optional(), + Tag: z.string().optional(), + // Schema 1.1 fields (to be added later): + // - ApplicableFunctionalLayers: Maps packages to functional layers + // - Config: Enhanced package metadata + // - SupportedFunctions: Function metadata +}); +export const OSPackageSchema = FunctionalPackageSchema.extend({ + Sources: z.array(PackageSourceWithUrlSchema).optional(), +}); + +export const MiscellaneousPackageSchema = OSPackageSchema; + +export const InfrastructurePackageSchema = z.object({ + Name: z.string().min(1, 'Name is required'), + Type: PackageType, + Architecture: z.array(z.string().min(1)).optional(), + SupportedFunctions: z + .array(z.object({ Name: z.string().min(1, 'Function name is required') })) + .min(1, 'At least one supported function is required'), + Uri: z.string().optional(), + Sources: z.array(PackageSourceWithUrlSchema).optional(), + Version: z.string().nullable().optional(), + Tag: z.string().optional(), + // Note: SupportedOS and Sources are NOT in the InfrastructurePackages schema + // Schema 1.1 fields (to be added later): + // - ApplicableFunctionalLayers: Maps packages to functional layers + // - Config: Enhanced package metadata +}); + +// ─── Driver schemas ─────────────────────────────────────────── + +export const DriverConfigSchema = z.object({ + DriverBrand: z.string().min(1, 'Driver Brand is required'), + DriverType: z.string().min(1, 'Driver Type is required'), +}); + +export const DriverPackageSchema = z.object({ + Name: z.string().min(1, 'Name is required'), + Type: PackageType, + Architecture: z + .array(z.string().min(1)) + .min(1, 'At least one architecture is required'), + Uri: z + .string() + .min(1, 'URI is required') + .refine((val) => URL_PATTERN.test(val), { + message: 'Uri must be a valid URL starting with http:// or https://', + }), + Config: DriverConfigSchema, + // Note: Tag, SupportedOS, and Sources are NOT in the DriverPackages schema + // Schema 1.1 field (to be added later): + // - ApplicableFunctionalLayers: Maps driver packages to functional layers + Version: z.string().min(1, 'Version is required'), +}); + +export const DriverSchema = z.object({ + Name: z.string(), + DriverPackages: z.array(z.string()), +}); + +// ─── Structural schemas ──────────────────────────────────── + +export const FunctionalLayerSchema = z.object({ + Name: z.string(), + Architecture: z.string().optional(), + FunctionalPackages: z.array(z.string()), + // Schema 1.1 field (to be added later): + // - ApplicableFunctionalLayers: Maps layer to other layers (optional) +}); + +export const BaseOSSchema = z.object({ + Name: z.string(), + Version: z.string(), + osPackages: z.array(z.string()), +}); + +export const InfrastructureSchema = z.object({ + Name: z.string(), + InfrastructurePackages: z.array(z.string()), +}); + +// ─── CatalogInner: everything under "Catalog" ────────────── + +export const CatalogInnerSchema = z.object({ + // Schema 1.0: Metadata fields + Name: z.string().default('Catalog'), + Version: z.string().default('1.0'), + Identifier: z.string().default('image-build'), + // Schema 1.1 field (to be added later): + // - CatalogSchemaVersion: "1.1" when using Schema 1.1 features + + // Schema 1.0: Structural sections + FunctionalLayer: z.array(FunctionalLayerSchema), + BaseOS: z.array(BaseOSSchema), + Infrastructure: z.array(InfrastructureSchema), + Drivers: z.array(DriverSchema).default([]), + DriverPackages: z.record(z.string(), DriverPackageSchema).default({}), + FunctionalPackages: z.record(z.string(), FunctionalPackageSchema), + OSPackages: z.record(z.string(), OSPackageSchema), + InfrastructurePackages: z.record( + z.string(), + InfrastructurePackageSchema, + ), + Miscellaneous: z.array(z.string()).default([]), +}); + +// ─── CatalogRoot: top-level { "Catalog": { ... } } ───────── + +export const CatalogRootSchema = z.object({ + Catalog: CatalogInnerSchema, +}); + +// ─── Inferred types ──────────────────────────────────────── + +export type PackageTypeValue = z.infer; +export type SupportedOS = z.infer; +export type PackageSource = z.infer; +export type PackageSourceWithUrl = z.infer; +export type FunctionalPackage = z.infer; +export type OSPackage = z.infer; +export type MiscellaneousPackage = z.infer; +export type InfrastructurePackage = z.infer< + typeof InfrastructurePackageSchema +>; +export type DriverConfig = z.infer; +export type DriverPackage = z.infer; +export type Driver = z.infer; +export type FunctionalLayer = z.infer; +export type BaseOS = z.infer; +export type InfrastructureType = z.infer; +export type CatalogInner = z.infer; +export type CatalogRoot = z.infer; diff --git a/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts b/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts new file mode 100644 index 0000000000..8df30630c0 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts @@ -0,0 +1,150 @@ +import type { CatalogRoot, DriverPackage } from '../schemas/catalogSchema'; + +const cleanPackage = (pkg: any, keepVersion = false, includeSupportedOS = true) => { + const cleaned: Record = { + Name: pkg.Name, + Type: pkg.Type, + Architecture: pkg.Architecture, + }; + + if (keepVersion || pkg.Version != null) cleaned.Version = pkg.Version; + if (pkg.Tag != null) cleaned.Tag = pkg.Tag; + if (pkg.Uri != null) cleaned.Uri = pkg.Uri; + + if (includeSupportedOS && pkg.SupportedOS != null) cleaned.SupportedOS = pkg.SupportedOS; + + if (pkg.Sources?.length) { + cleaned.Sources = pkg.Sources + .filter((s: any) => s.RepoName != null || s.Uri != null) + .map(({ RepoName, Uri }: any) => { + const src: Record = {}; + if (RepoName != null) src.RepoName = RepoName; + if (Uri != null) src.Uri = Uri; + return src; + }); + if (cleaned.Sources.length === 0) delete cleaned.Sources; + } + + return cleaned; +}; + +export function cleanCatalogForExport(catalog: CatalogRoot): CatalogRoot { + if (!catalog) return catalog; + + const cleaned = JSON.parse(JSON.stringify(catalog)); + + // Clean FunctionalPackages + if (cleaned.Catalog?.FunctionalPackages) { + Object.keys(cleaned.Catalog.FunctionalPackages).forEach(pkgId => { + cleaned.Catalog.FunctionalPackages[pkgId] = cleanPackage( + cleaned.Catalog.FunctionalPackages[pkgId] + ); + }); + } + + // Clean OSPackages + if (cleaned.Catalog?.OSPackages) { + Object.keys(cleaned.Catalog.OSPackages).forEach(pkgId => { + cleaned.Catalog.OSPackages[pkgId] = cleanPackage( + cleaned.Catalog.OSPackages[pkgId] + ); + }); + } + + // Clean InfrastructurePackages + if (cleaned.Catalog?.InfrastructurePackages) { + Object.keys(cleaned.Catalog.InfrastructurePackages).forEach(pkgId => { + cleaned.Catalog.InfrastructurePackages[pkgId] = cleanPackage( + cleaned.Catalog.InfrastructurePackages[pkgId], + true, + false, // InfrastructurePackages don't have SupportedOS + ); + }); + } + + // Clean DriverPackages (different schema - only required fields) + if (cleaned.Catalog?.DriverPackages) { + Object.keys(cleaned.Catalog.DriverPackages).forEach(pkgId => { + const pkg = cleaned.Catalog.DriverPackages[pkgId]; + // Build explicitly with only required fields + cleaned.Catalog.DriverPackages[pkgId] = { + Name: pkg.Name, + Type: pkg.Type, + Architecture: pkg.Architecture, + Version: pkg.Version, + Uri: pkg.Uri, + Config: pkg.Config, + }; + }); + } + + // Auto-derive BaseOS from OSPackages + if (cleaned.Catalog?.OSPackages) { + const osPackageIds = Object.keys(cleaned.Catalog.OSPackages).sort(); + + // Extract OS family and version from first package (or use defaults) + let osFamily = 'RHEL'; + let osVersion = '10.0'; + + if (osPackageIds.length > 0) { + const firstPkg = cleaned.Catalog.OSPackages[osPackageIds[0]]; + if (firstPkg?.SupportedOS && firstPkg.SupportedOS.length > 0) { + osFamily = firstPkg.SupportedOS[0].Name; + osVersion = firstPkg.SupportedOS[0].Version; + } + } + + // Update BaseOS to match OSPackages + cleaned.Catalog.BaseOS = [{ + Name: osFamily, + Version: osVersion, + osPackages: osPackageIds + }]; + } + + // Auto-derive Infrastructure from InfrastructurePackages + if (cleaned.Catalog?.InfrastructurePackages) { + const infraPackageIds = Object.keys(cleaned.Catalog.InfrastructurePackages).sort(); + + // Update Infrastructure to match InfrastructurePackages + cleaned.Catalog.Infrastructure = [{ + Name: 'csi', + InfrastructurePackages: infraPackageIds + }]; + } + + // Auto-derive Drivers from DriverPackages + if (cleaned.Catalog?.DriverPackages) { + const driverPackages = cleaned.Catalog.DriverPackages; + + // Group driver packages by DriverBrand and DriverType + // Use null character as separator to avoid conflicts with brand/type values + const driverGroups = new Map(); + + Object.entries(driverPackages).forEach(([pkgId, pkg]) => { + const driverPkg = pkg as DriverPackage; + const brand = driverPkg.Config?.DriverBrand || 'unknown'; + const type = driverPkg.Config?.DriverType || 'unknown'; + const key = `${brand}\0${type}`; + + if (!driverGroups.has(key)) { + driverGroups.set(key, []); + } + driverGroups.get(key)!.push(pkgId); + }); + + // Create Drivers array from grouped packages + const drivers = Array.from(driverGroups.entries()).map(([key, packageIds]) => { + const [brand, type] = key.split('\0'); + return { + Name: `${brand} ${type}`, + DriverPackages: packageIds.sort() + }; + }); + + // Update Drivers section + cleaned.Catalog.Drivers = drivers; + } + + return cleaned as CatalogRoot; +} diff --git a/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts b/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts new file mode 100644 index 0000000000..ea3250977e --- /dev/null +++ b/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts @@ -0,0 +1,64 @@ +const SECTION_LABELS: Record = { + FunctionalPackages: 'Package', + OSPackages: 'Package', + InfrastructurePackages: 'Package', + FunctionalLayer: 'Layer', + BaseOS: 'BaseOS', + DriverPackages: 'Driver package', + Drivers: 'Driver', + Miscellaneous: 'Miscellaneous item', +}; + +interface ApiError { + data?: { + details?: Array<{ loc?: string[]; msg?: string }>; + detail?: string | Array<{ loc?: string[]; msg?: string }>; + error?: string; + }; + response?: { data?: { detail?: string | Array<{ loc?: string[]; msg?: string }> } }; + message?: string; +} + +export function extractErrorMessage(err: ApiError | any): string { + const details = + err?.data?.details ?? err?.data?.detail ?? err?.response?.data?.detail; + + if (Array.isArray(details)) { + return details + .map((d: any) => { + const loc = d.loc?.join('.') ?? ''; + return `${loc} - ${d.msg ?? 'Unknown error'}`; + }) + .join('; '); + } + + return err?.data?.error ?? err?.message ?? 'Unknown error'; +} + +export function extractUserFriendlyErrorMessage(err: ApiError | any): string { + const details = err?.data?.details; + + if (Array.isArray(details)) { + return details + .map((d: any) => { + const loc: string[] = d.loc || []; + const msg = d.msg || 'Unknown error'; + + if (loc.length >= 5 && loc[0] === 'body' && loc[1] === 'Catalog') { + const section = loc[2]; + const itemId = loc[3]; + const field = loc[4]; + const label = SECTION_LABELS[section]; + + if (label) { + return `${label} '${itemId}', field '${field}': ${msg}`; + } + } + + return `${loc.join('.')} - ${msg}`; + }) + .join('; '); + } + + return extractErrorMessage(err); +} diff --git a/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx b/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx new file mode 100644 index 0000000000..a86fa5c7f6 --- /dev/null +++ b/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx @@ -0,0 +1,489 @@ +import { useState, useRef, ChangeEvent, DragEvent } from 'react'; +import Layout from '../../components/Layout'; +import LoadingSpinner from '../../components/LoadingSpinner'; +import type { CatalogRoot } from '../catalog-editor/schemas/catalogSchema'; + +interface CatalogSectionProps { + title: string; + count: number; + expanded: boolean; + onToggle: () => void; + children: React.ReactNode; + collapsedItems: string[]; +} + +const CatalogSection = ({ title, count, expanded, onToggle, children, collapsedItems }: CatalogSectionProps) => ( +
+
+

{title} ({count})

+ +
+ {expanded ? children : ( +
+ {collapsedItems.map((item, idx) => ( + {item} + ))} +
+ )} +
+); + +const CatalogViewer = () => { + const [catalogData, setCatalogData] = useState(null); + const [catalogFileName, setCatalogFileName] = useState(''); + const [parseError, setParseError] = useState(null); + const [isParsing, setIsParsing] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + // Expand/collapse state for each section + const [expandedSections, setExpandedSections] = useState>(new Set()); + + const toggleSection = (sectionId: string) => { + setExpandedSections(prev => { + const newSet = new Set(prev); + if (newSet.has(sectionId)) { + newSet.delete(sectionId); + } else { + newSet.add(sectionId); + } + return newSet; + }); + }; + + const processFile = async (file: File) => { + if (!file.name.endsWith('.json')) { + setParseError('Please upload a JSON file'); + return; + } + + setIsParsing(true); + setParseError(null); + + try { + const text = await file.text(); + + if (!text.trim()) { + setParseError('File is empty'); + return; + } + + const json = JSON.parse(text); + + // Validate catalog structure + if (!json.Catalog) { + setParseError('Invalid catalog file: Missing "Catalog" object'); + return; + } + + if (!json.Catalog.Name) { + setParseError('Invalid catalog file: Missing "Name" field in Catalog'); + return; + } + + if (!json.Catalog.Version) { + setParseError('Invalid catalog file: Missing "Version" field in Catalog'); + return; + } + + if (!json.Catalog.Identifier) { + setParseError('Invalid catalog file: Missing "Identifier" field in Catalog'); + return; + } + + setCatalogData(json); + setCatalogFileName(file.name.replace('.json', '')); + } catch (error) { + if (error instanceof SyntaxError) { + setParseError('Invalid JSON file: File contains malformed JSON syntax'); + } else { + setParseError(error instanceof Error ? error.message : 'Failed to parse catalog file'); + } + } finally { + setIsParsing(false); + } + }; + + const handleFileUpload = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) processFile(file); + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) processFile(file); + }; + + const renderFunctionalLayer = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'functional-layer'; + const isExpanded = expandedSections.has(sectionId); + return ( + toggleSection(sectionId)} + collapsedItems={catalog.FunctionalLayer?.map(layer => layer.Name) || []} + > + {catalog.FunctionalLayer?.map((layer, idx) => ( +
+

{layer.Name}

+
+ {layer.FunctionalPackages?.map((pkg, pkgIdx) => ( + {pkg} + ))} +
+
+ ))} +
+ ); + }; + + const renderBaseOS = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'base-os'; + const isExpanded = expandedSections.has(sectionId); + return ( + toggleSection(sectionId)} + collapsedItems={catalog.BaseOS?.map(os => `${os.Name} ${os.Version}`) || []} + > + {catalog.BaseOS?.map((os, idx) => ( +
+ {os.Name} {os.Version} +
+ {os.osPackages?.map((pkg, pkgIdx) => ( + {pkg} + ))} +
+
+ ))} +
+ ); + }; + + const renderInfrastructure = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'infrastructure'; + const isExpanded = expandedSections.has(sectionId); + return ( + toggleSection(sectionId)} + collapsedItems={catalog.Infrastructure?.map(infra => infra.Name) || []} + > + {catalog.Infrastructure?.map((infra, idx) => ( +
+

{infra.Name}

+
+ {infra.InfrastructurePackages?.map((pkg, pkgIdx) => ( + {pkg} + ))} +
+
+ ))} +
+ ); + }; + + const renderFunctionalPackages = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'functional-packages'; + const isExpanded = expandedSections.has(sectionId); + const packages = Object.entries(catalog.FunctionalPackages || {}); + return ( + toggleSection(sectionId)} + collapsedItems={packages.map(([id]) => id)} + > + + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
IDNameVersionTypeArchitecture
{id}{pkg.Name}{pkg.Version || '—'}{pkg.Type}{pkg.Architecture?.join(', ') || '—'}
+
+ ); + }; + + const renderOSPackages = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'os-packages'; + const isExpanded = expandedSections.has(sectionId); + const packages = Object.entries(catalog.OSPackages || {}); + return ( + toggleSection(sectionId)} + collapsedItems={packages.map(([id]) => id)} + > + + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
IDNameVersionTypeArchitecture
{id}{pkg.Name}{pkg.Version || '—'}{pkg.Type}{pkg.Architecture?.join(', ') || '—'}
+
+ ); + }; + + const renderInfrastructurePackages = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'infrastructure-packages'; + const isExpanded = expandedSections.has(sectionId); + const packages = Object.entries(catalog.InfrastructurePackages || {}); + return ( + toggleSection(sectionId)} + collapsedItems={packages.map(([id]) => id)} + > + + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
IDNameVersionTypeArchitecture
{id}{pkg.Name}{pkg.Version || '—'}{pkg.Type}{pkg.Architecture?.join(', ') || '—'}
+
+ ); + }; + + const renderDriverPackages = (catalog: CatalogRoot['Catalog']) => { + const sectionId = 'driver-packages'; + const isExpanded = expandedSections.has(sectionId); + const packages = Object.entries(catalog.DriverPackages || {}); + return ( + toggleSection(sectionId)} + collapsedItems={packages.map(([id]) => id)} + > + + + + + + + + + + + + {packages.map(([id, pkg]) => ( + + + + + + + + ))} + +
IDNameVersionTypeArchitecture
{id}{pkg.Name}{pkg.Version}{pkg.Type}{pkg.Architecture?.join(', ') || '—'}
+
+ ); + }; + + const renderDrivers = (catalog: CatalogRoot['Catalog']) => { + if (!catalog.Drivers || catalog.Drivers.length === 0) return null; + const sectionId = 'drivers'; + const isExpanded = expandedSections.has(sectionId); + return ( + toggleSection(sectionId)} + collapsedItems={catalog.Drivers.map(driver => driver.Name)} + > + {catalog.Drivers.map((driver, idx) => ( +
+

{driver.Name}

+
+ {driver.DriverPackages?.map((pkg, pkgIdx) => ( + {pkg} + ))} +
+
+ ))} +
+ ); + }; + + const renderMiscellaneous = (catalog: CatalogRoot['Catalog']) => { + if (!catalog.Miscellaneous || catalog.Miscellaneous.length === 0) return null; + const sectionId = 'miscellaneous'; + const isExpanded = expandedSections.has(sectionId); + return ( + toggleSection(sectionId)} + collapsedItems={catalog.Miscellaneous} + > +
    + {catalog.Miscellaneous.map((item, idx) => ( +
  • {item}
  • + ))} +
+
+ ); + }; + + return ( + +

Catalog Parser & Viewer

+

Upload a catalog JSON file to parse and view its structure and contents.

+ + {!catalogData ? ( +
+

Upload Catalog File

+
fileInputRef.current?.click()} + > + + + + + + +

Drag & Drop or Click to Upload

+

Supported format: JSON catalog file

+
+ + {isParsing && ( +
+ +

Parsing catalog file...

+
+ )} + + {parseError && ( +
+ {parseError} +
+ )} +
+ ) : ( +
+
+

Catalog: {catalogFileName}

+
+ v{catalogData.Catalog.Version} + {catalogData.Catalog.Identifier} +
+ +
+ + {renderFunctionalLayer(catalogData.Catalog)} + {renderBaseOS(catalogData.Catalog)} + {renderInfrastructure(catalogData.Catalog)} + {renderDrivers(catalogData.Catalog)} + {renderFunctionalPackages(catalogData.Catalog)} + {renderOSPackages(catalogData.Catalog)} + {renderInfrastructurePackages(catalogData.Catalog)} + {renderDriverPackages(catalogData.Catalog)} + {renderMiscellaneous(catalogData.Catalog)} +
+ )} +
+ ); +}; + +export default CatalogViewer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx b/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx new file mode 100644 index 0000000000..b82dcebbc3 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx @@ -0,0 +1,320 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { IPV4_PATTERN } from './schemas/common'; +import { DeploymentConfigsStep } from './steps/network/DeploymentConfigsStep'; +import { useConfigStore } from './configStore'; +import { useGenerateAll, useJobStatus } from '../../utils/hooks/useConfig'; +import { showAlert } from '../toast/toastStore'; +import type { JobStatus } from '../../utils/api'; + +interface JobStatusResponse extends JobStatus { + progress?: number; + error?: string; +} + +const bmcCredentialsSchema = z.object({ + ome_ip: z.string().regex(IPV4_PATTERN, 'OME IP must be a valid IPv4 address'), +}); + +type BmcCredentialsFormData = z.infer; + +const BMC_STEPS = [ + { id: 1, title: 'BMC Credentials', description: 'Enter Dell OpenManage Enterprise IP Address' }, + { id: 2, title: 'Network Configuration', description: 'Configure network settings for BMC discovery' }, + { id: 3, title: 'Summary & Generate', description: 'Review and generate discovery configuration files' }, +]; + +export const BmcDiscoveryFlow = () => { + const navigate = useNavigate(); + const updateWizardFields = useConfigStore((s) => s.updateWizardFields); + const wizardData = useConfigStore((s) => s.wizardData); + const setWizardActiveStep = useConfigStore((s) => s.setActiveStep); + const setConfigMode = useConfigStore((s) => s.setConfigMode); + const [activeStep, setActiveStep] = useState(1); + const [isGenerating, setIsGenerating] = useState(false); + const [generationError, setGenerationError] = useState(null); + const [generationProgress, setGenerationProgress] = useState(0); + const [generationComplete, setGenerationComplete] = useState(false); + const [jobId, setJobId] = useState(null); + const omeIpFromStore = (wizardData.ome_ip as string) || ''; + + const generateAll = useGenerateAll(); + const { data: jobStatus } = useJobStatus(jobId ?? '') as { data: JobStatusResponse | undefined }; + const activeJobStatus = jobId ? jobStatus : undefined; + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(bmcCredentialsSchema) as any, + mode: 'onTouched', + defaultValues: { + ome_ip: (wizardData.ome_ip as string) || '', + }, + }); + + const omeIp = watch('ome_ip'); + + // Check if network configuration is valid for step 2 + const hasValidNetworkConfig = useMemo(() => { + const networks = wizardData.Networks; + if (!Array.isArray(networks) || networks.length === 0) return false; + return networks.some((n: any) => n?.admin_network?.subnet?.trim?.()); + }, [wizardData.Networks]); + + const handleNext = () => { + if (activeStep === 1) { + // Sync form data to store before advancing + updateWizardFields({ ome_ip: omeIp }); + } + if (activeStep < BMC_STEPS.length) { + setActiveStep(activeStep + 1); + } + }; + + const handleBack = () => { + if (activeStep > 1) { + setActiveStep(activeStep - 1); + } + }; + + const handleBmcCredentialsSubmit = (data: BmcCredentialsFormData) => { + updateWizardFields({ ome_ip: data.ome_ip }); + handleNext(); + }; + + const handleGenerate = async () => { + setGenerationError(null); + if (!omeIpFromStore || !IPV4_PATTERN.test(omeIpFromStore)) { + setGenerationError('A valid OME IP address is required before generating.'); + return; + } + setIsGenerating(true); + setGenerationProgress(0); + + try { + // Prepare the data for backend - only include BMC discovery relevant fields + const bmcData = { + ome_ip: omeIpFromStore, + Networks: wizardData.Networks, + enable_bmc_discovery: true, + language: 'en_US.UTF-8', + files_to_generate: ['discovery_config.yml', 'network_spec.yml'], + }; + + // Call the backend API to generate files + const result = await generateAll.mutateAsync(bmcData) as unknown as { job_id: string }; + if (result.job_id) { + setJobId(result.job_id); + } + } catch (error) { + showAlert(error instanceof Error ? error.message : 'Generation failed', 'error'); + setGenerationError(error instanceof Error ? error.message : 'Failed to generate configuration files'); + setIsGenerating(false); + } + }; + + // Update progress based on job status + useEffect(() => { + if (activeJobStatus) { + setGenerationProgress(activeJobStatus.progress || 0); + + if (activeJobStatus.status === 'completed') { + setGenerationComplete(true); + setIsGenerating(false); + + // Store the generated files info + updateWizardFields({ + enable_bmc_discovery: true, + ome_ip: omeIpFromStore, + }); + + // Reset configMode to null so user sees configuration mode selection + setConfigMode(null); + + // Notify the user and navigate to the main wizard as soon as the job completes + showAlert('BMC discovery configuration generated successfully.', 'success'); + setWizardActiveStep(1); + navigate('/wizard'); + } else if (activeJobStatus.status === 'failed') { + showAlert(activeJobStatus.error || 'Generation failed', 'error'); + setGenerationError(activeJobStatus.error || 'Failed to generate configuration files'); + setIsGenerating(false); + } + } + }, [activeJobStatus, updateWizardFields, omeIpFromStore, navigate, setConfigMode, setWizardActiveStep]); + + const handleCancel = () => { + navigate('/wizard'); + }; + + const currentStep = BMC_STEPS.find(s => s.id === activeStep); + + return ( +
+
+

BMC Discovery Setup

+ {currentStep && ( +

{currentStep.description}

+ )} +
+ + {/* Step indicator */} +
+ {BMC_STEPS.map((step) => ( +
+
{step.id < activeStep ? '✓' : step.id}
+
{step.title}
+
+ ))} +
+ + {generationError && ( +
+ {generationError} +
+ )} + + {/* Step content */} +
+ {activeStep === 1 && ( +
+
+

Dell OpenManage Enterprise

+
+ + +

+ Enter the IP address of your Dell OpenManage Enterprise server +

+ {errors.ome_ip && {errors.ome_ip.message}} +
+
+ +
+ + +
+
+ )} + + {activeStep === 2 && ( +
+
+

Network Configuration

+ +
+ +
+ + +
+
+ )} + + {activeStep === 3 && ( +
+
+

Summary

+
+
+

OME IP Address

+

{wizardData.ome_ip as string || omeIp}

+
+
+

Network Configuration

+

+ Network settings will be included in the generated files +

+
+
+

Files to Generate

+
    +
  • discovery_config.yml
  • +
  • network_spec.yml
  • +
+
+
+
+ + {isGenerating && ( +
+

Generating Configuration Files...

+
+
+
+
+

+ {generationProgress}% Complete +

+
+
+ )} + + {generationComplete && ( +
+

Generation Complete!

+

+ Discovery configuration files have been generated successfully. +

+
+ )} + +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx b/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx new file mode 100644 index 0000000000..77752daf51 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx @@ -0,0 +1,112 @@ +import { useEffect } from 'react'; +import Layout from '../../components/Layout'; +import { useConfigStore } from './configStore'; +import { WIZARD_STEPS } from './constants'; +import DeploymentOverview from './components/DeploymentOverview'; +import { showConfirm } from '../confirmDialog/confirmDialogStore'; + +const ConfigurationWizard = () => { + const activeStep = useConfigStore((state) => state.activeStep); + const setActiveStep = useConfigStore((state) => state.setActiveStep); + const nextStep = useConfigStore((state) => state.nextStep); + const prevStep = useConfigStore((state) => state.prevStep); + const isCurrentStepValid = useConfigStore((state) => state.stepValidity[activeStep] ?? true); + const resetWizard = useConfigStore((state) => state.resetWizard); + const configSource = useConfigStore((state) => state.configSource); + const wizardData = useConfigStore((state) => state.wizardData); + const isStepEnabled = useConfigStore((state) => state.isStepEnabled); + const totalSteps = WIZARD_STEPS.length; + + // Manage step transitions and guard against disabled / out-of-bounds steps + useEffect(() => { + if (activeStep > totalSteps || activeStep < 0) { + setActiveStep(0); + return; + } + + if (configSource === 'preset' && Object.keys(wizardData).length > 0 && activeStep === 0) { + setActiveStep(1); + return; + } + + if (!isStepEnabled(activeStep) && activeStep > 0) { + let nearestEnabled = activeStep; + while (nearestEnabled > 0 && !isStepEnabled(nearestEnabled)) { + nearestEnabled--; + } + if (isStepEnabled(nearestEnabled)) { + setActiveStep(nearestEnabled); + } + } + }, [activeStep, configSource, wizardData, isStepEnabled, setActiveStep, totalSteps]); + + const stepIndex = activeStep > 0 ? activeStep - 1 : undefined; + const stepConfig = activeStep === 0 + ? { title: 'Deployment Overview' } + : stepIndex !== undefined ? WIZARD_STEPS[stepIndex] : { title: 'Configuration' }; + const StepComponent = activeStep === 0 + ? DeploymentOverview + : stepIndex !== undefined ? WIZARD_STEPS[stepIndex].component : undefined; + + return ( + +
+
+

{stepConfig?.title || 'Configuration'}

+ {stepConfig?.title === 'Telemetry Configuration' && ( + + )} +
+ +
+ {StepComponent && } +
+ +
+ + + {activeStep > 0 && ( + + )} + + {activeStep < totalSteps && ( + + )} +
+
+
+ ); +}; + +export default ConfigurationWizard; diff --git a/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx b/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx new file mode 100644 index 0000000000..e467722dc6 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import mermaid from 'mermaid'; + +let cachedPatternsSvg: string | null = null; +let cachedStepsSvg: string | null = null; +let mermaidInitialized = false; + +const patternsDiagram = ` +flowchart TD + classDef startClass fill:#e8f5e9,stroke:#1b5e20,stroke-width:3px,font-size:16px + classDef decisionClass fill:#fff3e0,stroke:#e65100,stroke-width:1px,font-size:10px + classDef smallDecisionClass fill:#fff3e0,stroke:#e65100,stroke-width:1px,font-size:9px + classDef processClass fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,font-size:13px + classDef optionalClass fill:#ffffff,stroke:#333,stroke-width:1px,stroke-dasharray: 5 5,font-size:10px + classDef endClass fill:#ffebee,stroke:#b71c1c,stroke-width:2px,font-size:15px + Start([Start Deployment Configuration]) --> ChooseMode{Choose
Configuration
Mode?} + ChooseMode -->|PXE Upload| PxeUpload[Upload PXE Mapping File] + ChooseMode -->|Manual| Manual[Manual Configuration] + PxeUpload --> ChooseCluster{Select
Cluster
Type?} + Manual --> ChooseCluster + ChooseCluster -->|Slurm| Slurm[Slurm Only] + ChooseCluster -->|K8s| K8s[Kubernetes Only] + ChooseCluster -->|Both| Both[Slurm + Kubernetes] + K8s --> EnableHA{Enable HA?} + Both --> EnableHA + EnableHA -->|Yes| HaConfig[Configure HA] + EnableHA -->|No| OptionalFeatures{Enable
Optional
Features?} + Slurm --> OptionalFeatures + K8s --> OptionalFeatures + Both --> OptionalFeatures + HaConfig --> OptionalFeatures + OptionalFeatures -->|Cloud-Init| CloudInit[Cloud-Init Configuration] + OptionalFeatures -->|Telemetry| Telemetry[Telemetry Configuration] + OptionalFeatures -->|Build Stream / GitLab| BuildStreamGitLab[Build Stream / GitLab Configuration] + OptionalFeatures -->|BMC Discovery| BmcDiscovery[BMC Discovery Flow] + BmcDiscovery --> BmcCredentials[BMC Credentials] + BmcCredentials --> BmcNetwork[Network Configuration] + BmcNetwork --> BmcGenerate[Run discovery playbook and generate bmc_pxe_mapping_file.csv] + BmcGenerate -->|Return to| ChooseMode + class Start startClass + class ChooseMode,ChooseCluster,OptionalFeatures smallDecisionClass + class EnableHA decisionClass + class PxeUpload,Manual,Slurm,K8s,Both,HaConfig processClass + class CloudInit,Telemetry,BuildStreamGitLab,BmcDiscovery,BmcCredentials,BmcNetwork,BmcGenerate optionalClass +`; + +const wizardStepsDiagram = ` +flowchart TD + classDef optional fill:#ffffff,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5 + subgraph Row1[ ] + direction LR + S1[1. Deployment Setup] --> S2[2. PXE Functional Groups] --> S3[3. Network Configuration] --> S4[4. Storage Configuration] + end + subgraph Row2[ ] + direction LR + S5[5. Cloud-Init Configuration] --> S6[6. Omnia Cluster Configuration] --> S7[7. Telemetry Configuration] --> S8[8. Build Stream Configuration] --> S9[9. Summary & Generate] + end + S4 --> S5 + class S5,S7,S8 optional +`; + +const DeploymentOverview = () => { + const [patternsSvg, setPatternsSvg] = useState(cachedPatternsSvg); + const [stepsSvg, setStepsSvg] = useState(cachedStepsSvg); + + useEffect(() => { + if (cachedPatternsSvg && cachedStepsSvg) return; + + if (!mermaidInitialized) { + mermaid.initialize({ startOnLoad: false }); + mermaidInitialized = true; + } + + if (!cachedPatternsSvg) { + mermaid.render('deployment-patterns-diagram', patternsDiagram) + .then(({ svg }) => { + cachedPatternsSvg = svg; + setPatternsSvg(svg); + }) + .catch((err) => console.error('Failed to render patterns diagram:', err)); + } + + if (!cachedStepsSvg) { + mermaid.render('deployment-wizard-steps-diagram', wizardStepsDiagram) + .then(({ svg }) => { + cachedStepsSvg = svg; + setStepsSvg(svg); + }) + .catch((err) => console.error('Failed to render wizard steps diagram:', err)); + } + }, []); + + return ( +
+
+

+ This flowchart shows the high-level deployment configuration patterns available in the wizard. +

+

+ This diagram describes the deployment configuration flow: start by choosing a configuration mode + (PXE Upload or Manual) and a cluster type, optionally enable high availability, and enable optional + features such as Cloud-Init, Telemetry, Build Stream/GitLab, and BMC Discovery. The BMC Discovery + flow runs a discovery playbook and generates a bmc_pxe_mapping_file.csv, then returns to choose the + configuration mode. +

+ {patternsSvg ? ( +
+ ) : ( +

Loading diagram...

+ )} +
+ +
+

Wizard Steps

+

+ The wizard walks through these steps in order. +

+

+ This diagram lists the wizard steps in order: Deployment Setup, PXE Functional Groups, Network + Configuration, Storage Configuration, Cloud-Init Configuration, Omnia Cluster Configuration, Telemetry + Configuration, Build Stream Configuration, and Summary & Generate. + Cloud-Init, Telemetry, and Build Stream are optional. +

+ {stepsSvg ? ( +
+ ) : ( +

Loading diagram...

+ )} +
+
+ ); +}; + +export default DeploymentOverview; diff --git a/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx b/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx new file mode 100644 index 0000000000..ce10778c38 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; + +type DnsInputProps = { + value: string[]; + onChange: (val: string[]) => void; + onBlur: () => void; + error?: { message?: string } | Array<{ message?: string } | undefined>; + placeholder?: string; +}; + +export const DnsInput = ({ value, onChange, onBlur, error, placeholder }: DnsInputProps) => { + const [displayValue, setDisplayValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + // Sync external value → display, but only when not actively editing + useEffect(() => { + if (!isFocused) { + setDisplayValue(Array.isArray(value) ? value.join(', ') : ''); + } + }, [value, isFocused]); + + const errMsg = typeof error === 'object' && !Array.isArray(error) + ? error?.message + : Array.isArray(error) + ? error.find((e) => e)?.message + : undefined; + + return ( + <> + setIsFocused(true)} + onChange={(e) => setDisplayValue(e.target.value)} + onBlur={() => { + const parsed = displayValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + onChange(parsed); + setDisplayValue(parsed.join(', ')); // reflect cleanup + setIsFocused(false); + onBlur(); + }} + /> + {errMsg && {errMsg}} + + ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/components/FormField.tsx b/utils/gui/frontend/src/features/configuration-wizard/components/FormField.tsx new file mode 100644 index 0000000000..56d985fa26 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/components/FormField.tsx @@ -0,0 +1,55 @@ +import { Controller, ControllerProps, FieldValues } from 'react-hook-form'; + +interface FormFieldProps extends Omit, 'render'> { + label: string; + required?: boolean; + placeholder?: string; + type?: 'text' | 'number' | 'password' | 'select'; + options?: { value: string; label: string }[]; + className?: string; +} + +export const FormField = ({ + label, + required = false, + placeholder, + type = 'text', + options, + className = '', + ...controllerProps +}: FormFieldProps) => { + const requiredText = required ? '(Required)' : '(Optional)'; + const displayLabel = `${label} ${requiredText}`; + + return ( + ( +
+ + {type === 'select' && options ? ( + + ) : ( + + )} + {error && {error.message}} +
+ )} + /> + ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx b/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx new file mode 100644 index 0000000000..579e18ad58 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; + +export interface TabConfig { + id: string; + label: string; + component: ReactNode; +} + +interface TabsProps { + tabs: TabConfig[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +export const Tabs = ({ tabs, activeTab, onTabChange }: TabsProps) => { + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ {tabs.find((tab) => tab.id === activeTab)?.component} +
+
+ ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/configStore.ts b/utils/gui/frontend/src/features/configuration-wizard/configStore.ts new file mode 100644 index 0000000000..037557bb32 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/configStore.ts @@ -0,0 +1,282 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { ValidationError } from './utils/l2Validation'; +import { WIZARD_STEPS } from './constants'; + +export type ConfigSource = 'fresh' | 'preset' | 'upload'; + +interface ConfigState { + // UI State + activeStep: number + setActiveStep: (step: number) => void + nextStep: () => void + prevStep: () => void + + // Config Source + configSource: ConfigSource + setConfigSource: (source: ConfigSource) => void + + // Wizard Data + wizardData: Record + setWizardData: (data: Record) => void + updateWizardField: (field: string, value: unknown) => void + updateWizardFields: (fields: Record) => void + resetWizard: () => void + + // Step Validation + stepValidity: Record + setStepValid: (step: number, valid: boolean) => void + validationErrors: ValidationError[] + setValidationErrors: (errors: ValidationError[]) => void + clearValidationErrors: () => void + removeValidationErrorsByField: (fields: string[]) => void + + // UI Toggles + sidebarOpen: boolean + toggleSidebar: () => void + wizardExpanded: boolean + setWizardExpanded: (expanded: boolean) => void + catalogExpanded: boolean + setCatalogExpanded: (expanded: boolean) => void + buildConfigExpanded: boolean + setBuildConfigExpanded: (expanded: boolean) => void + localRepoExpanded: boolean + setLocalRepoExpanded: (expanded: boolean) => void + telemetryActiveTab: string + setTelemetryActiveTab: (tab: string) => void + + // Guiding Screen State + clusterType: 'slurm' | 'k8s' | 'both' | null + setClusterType: (type: 'slurm' | 'k8s' | 'both' | null) => void + enableHa: boolean + setEnableHa: (enabled: boolean) => void + enableCloudInit: boolean + setEnableCloudInit: (enabled: boolean) => void + enableBmcDiscovery: boolean + setEnableBmcDiscovery: (enabled: boolean) => void + selectedTelemetrySources: string[] + setSelectedTelemetrySources: (sources: string[]) => void + enableBuildStream: boolean + setEnableBuildStream: (enabled: boolean) => void + enableGitlab: boolean + setEnableGitlab: (enabled: boolean) => void + enableTelemetry: boolean + setEnableTelemetry: (enabled: boolean) => void + configMode: 'pxe_upload' | 'manual' | null + setConfigMode: (mode: 'pxe_upload' | 'manual' | null) => void + isStepEnabled: (stepId: number) => boolean +} + +export const useConfigStore = create()( + persist( + (set, get) => ({ + // UI State + activeStep: 0, + setActiveStep: (step) => set({ activeStep: step }), + nextStep: () => set((state) => { + const current = state.activeStep; + let next = current + 1; + const totalSteps = WIZARD_STEPS.length; + while (next <= totalSteps && !get().isStepEnabled(next)) { + next++; + } + return { activeStep: next }; + }), + prevStep: () => set((state) => { + const current = state.activeStep; + let prev = current - 1; + while (prev >= 1 && !get().isStepEnabled(prev)) { + prev--; + } + return { activeStep: Math.max(0, prev) }; + }), + + // Config Source + configSource: 'fresh', + setConfigSource: (source) => set({ configSource: source }), + + // Wizard Data + wizardData: {}, + setWizardData: (data) => set({ wizardData: data }), + updateWizardField: (field, value) => set((state) => ({ + wizardData: { ...state.wizardData, [field]: value }, + })), + updateWizardFields: (fields) => set((state) => ({ + wizardData: { ...state.wizardData, ...fields }, + })), + resetWizard: () => set({ + wizardData: {}, + activeStep: 0, + stepValidity: {}, + validationErrors: [], + clusterType: null, + enableHa: false, + enableCloudInit: false, + enableBmcDiscovery: false, + selectedTelemetrySources: [], + enableBuildStream: false, + enableGitlab: false, + enableTelemetry: false, + configMode: null, + }), + + // Step Validation + stepValidity: {}, + setStepValid: (step, valid) => set((state) => ({ + stepValidity: { ...state.stepValidity, [step]: valid }, + })), + validationErrors: [], + setValidationErrors: (errors) => set({ validationErrors: errors }), + clearValidationErrors: () => set({ validationErrors: [] }), + removeValidationErrorsByField: (fields) => + set((state) => ({ + validationErrors: state.validationErrors.filter( + (err) => !fields.includes(err.field) + ), + })), + + // UI Toggles + sidebarOpen: true, + toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), + wizardExpanded: false, + setWizardExpanded: (expanded) => set({ wizardExpanded: expanded }), + catalogExpanded: false, + setCatalogExpanded: (expanded) => set({ catalogExpanded: expanded }), + buildConfigExpanded: false, + setBuildConfigExpanded: (expanded) => set({ buildConfigExpanded: expanded }), + localRepoExpanded: false, + setLocalRepoExpanded: (expanded) => set({ localRepoExpanded: expanded }), + telemetryActiveTab: 'idrac', + setTelemetryActiveTab: (tab) => set({ telemetryActiveTab: tab }), + + // Guiding Screen State + clusterType: null, + setClusterType: (type) => set((state) => { + const includesSlurm = (t: typeof type) => t === 'slurm' || t === 'both'; + const includesK8s = (t: typeof type) => t === 'k8s' || t === 'both'; + + const updates: Partial & { wizardData: Record } = { + clusterType: type, + wizardData: { ...state.wizardData }, + }; + + // Clear slurm data when the new selection no longer includes Slurm + if (!includesSlurm(type) && includesSlurm(state.clusterType)) { + updates.wizardData.slurm_cluster = undefined; + } + + // Clear k8s and HA data when the new selection no longer includes Kubernetes + if (!includesK8s(type) && includesK8s(state.clusterType)) { + updates.wizardData.service_k8s_cluster = undefined; + updates.wizardData.service_k8s_cluster_ha = undefined; + updates.wizardData.enable_ha = undefined; + updates.enableHa = false; + } + + return updates; + }), + enableHa: false, + setEnableHa: (enabled) => set({ enableHa: enabled }), + enableCloudInit: false, + setEnableCloudInit: (enabled) => set({ enableCloudInit: enabled }), + enableBmcDiscovery: false, + setEnableBmcDiscovery: (enabled) => set({ enableBmcDiscovery: enabled }), + selectedTelemetrySources: [], + setSelectedTelemetrySources: (sources) => set({ selectedTelemetrySources: sources }), + enableBuildStream: false, + setEnableBuildStream: (enabled) => set({ enableBuildStream: enabled }), + enableGitlab: false, + setEnableGitlab: (enabled) => set({ enableGitlab: enabled }), + enableTelemetry: false, + setEnableTelemetry: (enabled) => set({ enableTelemetry: enabled }), + configMode: null, + setConfigMode: (mode) => set({ configMode: mode }), + isStepEnabled: (stepId: number) => { + const state = get(); + // Step 0: Deployment Overview - always enabled + if (stepId === 0) return true; + // Step 1: Deployment Setup - always enabled + if (stepId === 1) return true; + // Lock all other steps until configMode and clusterType are selected + if (!state.configMode || !state.clusterType) return false; + // Step 2: PXE Functional Groups - always enabled after configMode is set (for both modes) + if (stepId === 2) return true; + // Step 3: Network Configuration - always enabled after configMode is set + if (stepId === 3) return true; + // Step 4: Storage Configuration - always enabled + if (stepId === 4) return true; + // Step 5: Cloud-Init Configuration - only when enabled + if (stepId === 5) return state.enableCloudInit; + // Step 6: Omnia Cluster Config - always enabled + if (stepId === 6) return true; + // Step 7: Telemetry Configuration - only for K8s or Both AND when telemetry is enabled + if (stepId === 7) return (state.clusterType === 'k8s' || state.clusterType === 'both') && state.enableTelemetry; + // Step 8: Build Stream Config - only when Build Stream or GitLab enabled + if (stepId === 8) return state.enableBuildStream || state.enableGitlab; + // Step 9: Summary & Generate - always enabled + if (stepId === 9) return true; + return false; + }, + + }), + { + name: 'omnia-config-storage', + version: 3, + migrate: (persistedState, version) => { + if (version === 0) { + // Handle migration from v0 → v1 if needed in the future + } + return persistedState as ConfigState; + }, + merge: (persistedState, currentState) => ({ + ...currentState, + ...(persistedState as Partial), + }), + partialize: (state) => ({ + // Persist all state except sensitive credential fields + activeStep: state.activeStep, + configSource: state.configSource, + wizardExpanded: state.wizardExpanded, + buildConfigExpanded: state.buildConfigExpanded, + localRepoExpanded: state.localRepoExpanded, + telemetryActiveTab: state.telemetryActiveTab, + clusterType: state.clusterType, + enableHa: state.enableHa, + enableCloudInit: state.enableCloudInit, + enableBmcDiscovery: state.enableBmcDiscovery, + selectedTelemetrySources: state.selectedTelemetrySources, + enableBuildStream: state.enableBuildStream, + enableGitlab: state.enableGitlab, + enableTelemetry: state.enableTelemetry, + configMode: state.configMode, + wizardData: (() => { + const filteredData: Record = {}; + for (const [key, value] of Object.entries(state.wizardData)) { + // Always preserve PXE mapping data + if (key === 'pxe_mapping_data' || key === 'pxe_mapping_file_path') { + filteredData[key] = value; + } + // Exclude password fields within user_registry_credential + else if (key === 'user_registry_credential' && Array.isArray(value)) { + const filteredCredentials = value.map((cred: any) => ({ + ...cred, + password: undefined + })); + filteredData[key] = filteredCredentials; + } + // Exclude other sensitive fields + else if (['password', 'admin_password', 'bmc_password', 'secret_key', 'api_secret', 'auth_token', 'access_token'].includes(key)) { + // Skip these keys entirely + } + else { + // Keep all other data + filteredData[key] = value; + } + } + return filteredData; + })(), + sidebarOpen: state.sidebarOpen, + }), + } + ) +) diff --git a/utils/gui/frontend/src/features/configuration-wizard/constants.ts b/utils/gui/frontend/src/features/configuration-wizard/constants.ts new file mode 100644 index 0000000000..3a53060f73 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/constants.ts @@ -0,0 +1,21 @@ +import { DeploymentSetupStep } from './steps/deployment-setup/DeploymentSetupStep'; +import { PxeFunctionalGroupsStep } from './steps/pxe/PxeFunctionalGroupsStep'; +import { DeploymentConfigsStep as NetworkSpecStep } from './steps/network/DeploymentConfigsStep'; +import { StorageConfigStep } from './steps/storage/StorageConfigStep'; +import { CloudInitConfigStep } from './steps/cloud-init/CloudInitConfigStep'; +import { OmniaHaDiscoveryStep } from './steps/omnia/OmniaHaDiscoveryStep'; +import { TelemetryConfigStorageStep } from './steps/telemetry/TelemetryConfigStorageStep'; +import { BuildStreamGitLabStep } from './steps/build-stream/BuildStreamGitLabStep'; +import { SummaryAndGenerateStep } from './steps/summary/SummaryAndGenerateStep'; + +export const WIZARD_STEPS = [ + { id: 1, title: 'Deployment Setup', description: 'Select cluster type, configure optional features like Cloud-Init, BMC Discovery, Telemetry, Build Stream, and GitLab', component: DeploymentSetupStep }, + { id: 2, title: 'PXE Functional Groups', description: 'Configure PXE boot mapping, functional groups, DHCP settings, DNS, and kernel overrides for network booting', component: PxeFunctionalGroupsStep }, + { id: 3, title: 'Network Configuration', description: 'Configure admin and InfiniBand networks including subnets, IP addresses, DNS, NTP servers, and additional subnets', component: NetworkSpecStep }, + { id: 4, title: 'Storage Configuration', description: 'Configure storage mounts, mount profiles, PowerVault iSCSI, swap files, and S3 storage backend', component: StorageConfigStep }, + { id: 5, title: 'Cloud-Init Configuration', description: 'Configure additional cloud-init write_files and runcmd for common and per-functional-group node provisioning', component: CloudInitConfigStep }, + { id: 6, title: 'Omnia Cluster Configuration', description: 'Configure Slurm clusters, Kubernetes service clusters, high availability settings, and security configuration', component: OmniaHaDiscoveryStep }, + { id: 7, title: 'Telemetry Configuration', description: 'Configure telemetry sources (iDRAC, LDMS, DCGM, PowerScale, UFM, VAST, OME), bridges, and storage sinks (VictoriaMetrics, VictoriaLogs, Kafka)', component: TelemetryConfigStorageStep }, + { id: 8, title: 'Build Stream Configuration', description: 'Configure BuildStream host and GitLab integration for catalog management with project settings and resource limits', component: BuildStreamGitLabStep }, + { id: 9, title: 'Summary & Generate', description: 'Review all configuration settings and generate the deployment configuration files for download', component: SummaryAndGenerateStep }, +] as const; diff --git a/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts b/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts new file mode 100644 index 0000000000..cbd339d4ac --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from 'react'; +import { FieldErrors } from 'react-hook-form'; +import type { ValidationError } from '../utils/l2Validation'; + +const normalizePath = (path: string): string[] => + path.replace(/\[(\d+)\]/g, '.$1').split('.'); + +export interface FormFieldError { + message: string; + type?: string; +} + +export const useFormErrors = ( + errors: FieldErrors, + validationErrors?: ValidationError[] +): (path: string) => FormFieldError | undefined => { + const l2ErrorMap = useMemo(() => { + const map = new Map(); + validationErrors?.forEach((e) => + map.set(normalizePath(e.field).join('.'), e) + ); + return map; + }, [validationErrors]); + + return useCallback( + (path: string) => { + const normalizedPath = normalizePath(path); + const rhfError = normalizedPath.reduce((obj, key) => obj?.[key], errors); + if (rhfError?.message) return rhfError; + + const l2Error = l2ErrorMap.get(normalizedPath.join('.')); + if (l2Error) return { message: l2Error.message, type: 'validate' }; + + return undefined; + }, + [errors, l2ErrorMap] + ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts new file mode 100644 index 0000000000..1464b27a1e --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { IPV4_PATTERN } from './common'; + +// Combined schema for both GitLab and Build Stream +export const buildStreamGitLabSchema = z.object({ + // Build Stream fields + enable_build_stream: z.boolean().default(false), + build_stream_host_ip: z.union([ + z.literal(''), + z.string().regex(IPV4_PATTERN, 'Must be a valid IPv4 address'), + ]).default(''), + build_stream_port: z.coerce.number().min(1).max(65535).default(8010), + aarch64_inventory_host_ip: z.union([ + z.literal(''), + z.string().regex(IPV4_PATTERN, 'Must be a valid IPv4 address'), + ]).optional(), + + // GitLab fields (with defaults matching input/gitlab_config.yml) + enable_gitlab: z.boolean().default(false), + gitlab_host: z.string().default(''), + gitlab_project_name: z.string().default('omnia-catalog'), + gitlab_project_visibility: z.enum(['private', 'internal', 'public']).default('private'), + gitlab_default_branch: z.string().default('main'), + gitlab_https_port: z.coerce.number().min(1).max(65535).default(443), + gitlab_min_storage_gb: z.coerce.number().min(20).default(20), + gitlab_min_memory_gb: z.coerce.number().min(1).default(4), + gitlab_min_cpu_cores: z.coerce.number().min(1).default(2), + gitlab_puma_workers: z.coerce.number().min(1).default(2), + gitlab_sidekiq_concurrency: z.coerce.number().min(1).default(10), +}) + .refine( + (data) => { + if (!data.enable_build_stream) return true; + return !!data.build_stream_host_ip && data.build_stream_host_ip.length > 0; + }, + { message: 'Build Stream Host IP is required', path: ['build_stream_host_ip'] } + ) + .refine( + (data) => { + if (!data.enable_gitlab) return true; + return !!data.gitlab_host && IPV4_PATTERN.test(data.gitlab_host); + }, + { message: 'GitLab Host IP is required and must be valid IPv4', path: ['gitlab_host'] } + ) + .refine( + (data) => { + if (!data.enable_gitlab) return true; + return !!data.gitlab_project_name && data.gitlab_project_name.trim().length > 0; + }, + { message: 'GitLab Project Name is required', path: ['gitlab_project_name'] } + ); + +export type BuildStreamGitLabFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts new file mode 100644 index 0000000000..f2acafa4a7 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const ABSOLUTE_PATH_PATTERN = /^\/\S+$/; +const OCTAL_PERMS_PATTERN = /^[0-7]{3,4}$/; + +// --- write_files entry --- +const writeFileEntrySchema = z.object({ + path: z.string().regex(ABSOLUTE_PATH_PATTERN, 'Must be an absolute path'), + content: z.string().min(1, 'Content is required'), + permissions: z.string().regex(OCTAL_PERMS_PATTERN, 'Octal 3-4 digits, e.g. 0644').optional(), +}); + +// --- runcmd entry --- +const runcmdEntrySchema = z.object({ + command: z.string(), +}); + +// --- Cloud-init section (common or per-group) --- +const cloudInitSectionSchema = z.object({ + write_files: z.array(writeFileEntrySchema).optional().default([]), + runcmd: z.array(runcmdEntrySchema).optional().default([]), +}).strict(); // Strict mode to reject additional properties like bootcmd, network, etc. + +// --- Top-level schema --- +export const cloudInitConfigSchema = z.object({ + cloud_init_common: cloudInitSectionSchema.optional().default({ write_files: [], runcmd: [] }), + cloud_init_groups: z.array(z.object({ + group_name: z.string().min(1, 'Group name is required'), + write_files: z.array(writeFileEntrySchema).optional().default([]), + runcmd: z.array(runcmdEntrySchema).optional().default([]), + })).optional().default([]), +}); + +export type CloudInitConfigFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts new file mode 100644 index 0000000000..84a716dabe --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; + +// IPv4 octet pattern: 0-255 +const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'; +// IPv4 address pattern: four octets separated by dots +const IPV4_ADDR = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}`; + +// Regex patterns matching L1 validation +export const IPV4_PATTERN = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +export const IPV4_OR_EMPTY_PATTERN = /^$|^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +export const IPV4_OR_HOSTNAME_PATTERN = /^(?:(?:(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})$/; +export const STORAGE_SIZE_PATTERN = /^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)$/; +export const GIGABYTES_PATTERN = /^[1-9][0-9]*G$/; +export const URL_PATTERN = /^(https?:\/\/).+/; +export const HOST_PORT_PATTERN = /^[a-zA-Z0-9.-]+:[0-9]+$/; +export const SCRAPE_DURATION_PATTERN = /^[0-9]+[smh]$/; +export const CPU_RESOURCE_PATTERN = /^[0-9]+m?$/; // Matches e.g., "100m", "500m", "1", "2" +export const MEMORY_RESOURCE_PATTERN = /^[0-9]+(Ki|Mi|Gi|Ti|Pi|Ei)$/; // Matches e.g., "256Mi", "512Mi", "1Gi", "8Gi" +export const YAML_FILE_PATTERN = /^.*\.(yml|yaml)$/; // Matches files ending with .yml or .yaml +export const HOSTNAME_OR_URL_PATTERN = /^(https?:\/\/)?[a-zA-Z0-9.-]+(:[0-9]+)?$/; // Matches hostname or URL +export const CERT_PATH_PATTERN = /^$|^\/[a-zA-Z0-9/._-]*\.crt$/; // Matches cert path or empty +export const NETMASK_BITS_PATTERN = /^(1[0-9]|2[0-9]|[1-9])$|^3[0-2]$/; +// Matches IP range format: IP-IP (e.g., "172.16.0.1-172.16.0.254") +export const DYNAMIC_RANGE_PATTERN = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)-(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +// Matches RPM GPG key URIs (e.g., "https://example.com/key.gpg") +export const GPGKEY_PATTERN = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+)$/; +export const KEY_PATH_PATTERN = /^$|^\/[a-zA-Z0-9/._-]*\.key$/; +export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_\-\.]*$/; +// CIDR notation: IP/prefix (prefix 0-32) +export const CIDR_PATTERN = new RegExp(`^${IPV4_ADDR}\\/(?:[0-9]|[12][0-9]|3[0-2])$`); +// Pod external IP range: IP-IP or CIDR or empty +export const POD_EXTERNAL_IP_RANGE_PATTERN = new RegExp(`^${IPV4_ADDR}-${IPV4_ADDR}$|^${IPV4_ADDR}\\/(?:[0-9]|[12][0-9]|3[0-2])$|^$`); + +// Slurm config file names +export const SLURM_CONFIG_FILE_NAMES = [ + 'slurm', 'cgroup', 'slurmdbd', 'gres', 'acct_gather', + 'helpers', 'job_container', 'mpi', 'oci', 'topology', 'burst_buffer', +] as const; + +export type SlurmConfigFileName = typeof SLURM_CONFIG_FILE_NAMES[number]; + +// Schema 1.1 Package Schema with applicable_functional_layers +export const packageSchema = z.object({ + name: z.string(), + type: z.enum(['rpm', 'rpm_repo', 'tarball', 'git', 'image', 'pip_module']), + version: z.string().optional(), + uri: z.string().url().optional(), + repo_name: z.string().optional(), + architecture: z.union([z.string(), z.array(z.string())]).optional(), + // Schema 1.1 fields + applicable_functional_layers: z.array(z.string()).optional(), + // Driver-specific configuration (Schema 1.1) + config: z.object({ + DriverBrand: z.string().optional(), + DriverType: z.string().optional(), + }).catchall(z.unknown()).optional(), + // Infrastructure supported functions (Schema 1.1) + supported_functions: z.array(z.object({ + Name: z.string(), + }).catchall(z.unknown())).optional(), +}).passthrough(); // Preserve unknown keys for forward compatibility + +export type Package = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts new file mode 100644 index 0000000000..e7213dc524 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import { IPV4_PATTERN, NETMASK_BITS_PATTERN, DYNAMIC_RANGE_PATTERN, IPV4_OR_HOSTNAME_PATTERN } from './common'; + +// Shared additional subnet entry schema (used by admin_network and top-level additional_subnets) +const additionalSubnetEntrySchema = z.object({ + subnet: z.string().regex(IPV4_PATTERN, 'Subnet must be a valid IPv4 address'), + netmask_bits: z.string().regex(NETMASK_BITS_PATTERN, 'Netmask bits must be between 1 and 32'), + router: z.string().regex(IPV4_PATTERN, 'Router must be a valid IPv4 address'), + dynamic_range: z.string().regex(DYNAMIC_RANGE_PATTERN, 'Dynamic range must be in format IP-IP'), +}); + +// Admin Network Schema +const adminNetworkSchema = z.object({ + oim_nic_name: z.string().min(1, 'Network interface name is required'), + subnet: z.string().regex(IPV4_PATTERN, 'Admin subnet must be a valid IPv4 address'), + netmask_bits: z.string().regex(NETMASK_BITS_PATTERN, 'Netmask bits must be between 1 and 32'), + primary_oim_admin_ip: z.string().regex(IPV4_PATTERN, 'Primary OIM admin IP must be a valid IPv4 address'), + primary_oim_bmc_ip: z.union([ + z.literal(''), + z.string().regex(IPV4_PATTERN, 'Primary OIM BMC IP must be a valid IPv4 address'), + ]), + dynamic_range: z.string().regex(DYNAMIC_RANGE_PATTERN, 'Dynamic range must be in format IP-IP'), + dns: z.array(z.string().regex(IPV4_PATTERN, 'DNS server must be a valid IPv4 address')).optional(), + ntp_servers: z.array(z.object({ + address: z.string().regex(IPV4_OR_HOSTNAME_PATTERN, 'NTP server address must be a valid IPv4 address or hostname'), + type: z.enum(['server', 'pool']), + })).optional(), + additional_subnets: z.array(additionalSubnetEntrySchema).optional(), +}); + +// IB Network Schema (all fields optional; if any IB value is provided, subnet and netmask are required) +const ibNetworkSchema = z.object({ + subnet: z.string().regex(IPV4_PATTERN, 'IB subnet must be a valid IPv4 address').or(z.literal('')).optional(), + netmask_bits: z.string().regex(NETMASK_BITS_PATTERN, 'IB netmask bits must be between 1 and 32').or(z.literal('')).optional(), + dns: z.array(z.string().regex(IPV4_PATTERN, 'DNS server must be a valid IPv4 address')).optional(), +}).refine( + (data) => { + const hasAnyIbValue = + (data.subnet?.trim() ?? '') !== '' || + (data.netmask_bits?.trim() ?? '') !== '' || + (data.dns && data.dns.length > 0); + if (hasAnyIbValue) { + return (data.subnet?.trim() ?? '') !== '' && (data.netmask_bits?.trim() ?? '') !== ''; + } + return true; + }, + { message: 'IB subnet and netmask bits are required when configuring InfiniBand', path: ['subnet'] } +); + +// Deployment Configs Schema (based on network_spec.json) +export const deploymentConfigsSchema = z.object({ + Networks: z.array(z.union([ + z.object({ admin_network: adminNetworkSchema }), + z.object({ ib_network: ibNetworkSchema }), + z.object({ additional_subnets: z.array(additionalSubnetEntrySchema) }), + ])).min(1, 'At least one network configuration is required') + .refine( + (networks) => networks.some((n) => 'admin_network' in n), + { message: 'At least one admin_network must be provided' } + ) + .refine( + (networks) => { + const adminNet = networks.find((n) => 'admin_network' in n); + const ibNet = networks.find((n) => 'ib_network' in n); + if (adminNet && ibNet) { + const adminNetmask = (adminNet as any).admin_network.netmask_bits; + const ibNetmask = (ibNet as any).ib_network.netmask_bits; + if (adminNetmask?.trim() && ibNetmask?.trim()) { + return adminNetmask === ibNetmask; + } + } + return true; + }, + { message: 'IB network netmask_bits must match admin network netmask_bits' } + ), +}); + +export type DeploymentConfigsFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts new file mode 100644 index 0000000000..8b51ae82ec --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +// export used +export { pxeFunctionalGroupsSchema, type PxeFunctionalGroupsFormData } from './pxeFunctionalGroups'; +export { deploymentConfigsSchema, type DeploymentConfigsFormData } from './deploymentConfigs'; +export { buildStreamGitLabSchema, type BuildStreamGitLabFormData } from './buildStreamGitLab'; +export { localRepoUserRegistrySchema, type LocalRepoUserRegistryFormData, localRepoUbuntuSchema, type LocalRepoUbuntuFormData, getLocalRepoOsSchema } from './localRepoUserRegistry'; +export { omniaHaDiscoverySchema, serviceK8sClusterHaSchema, type OmniaHaDiscoveryFormData } from './omniaHaDiscoveryConfig'; +export { telemetryConfigStorageSchema, type TelemetryConfigStorageFormData } from './telemetryConfigStorage'; +export { storageConfigSchema, type StorageConfigFormData } from './storageConfig'; +export { cloudInitConfigSchema, type CloudInitConfigFormData } from './cloudInitConfig'; + +// Re-export common patterns +export * from './common'; + +// Complete Wizard Schema (combines all sections) +import { pxeFunctionalGroupsSchema } from './pxeFunctionalGroups'; +import { deploymentConfigsSchema } from './deploymentConfigs'; +import { omniaHaDiscoverySchema } from './omniaHaDiscoveryConfig'; +import { telemetryConfigStorageSchema } from './telemetryConfigStorage'; +import { buildStreamGitLabSchema } from './buildStreamGitLab'; +import { storageConfigSchema } from './storageConfig'; +import { cloudInitConfigSchema } from './cloudInitConfig'; + +export const wizardSchema = z.object({ + ...pxeFunctionalGroupsSchema.shape, + ...deploymentConfigsSchema.shape, + ...storageConfigSchema.shape, + ...cloudInitConfigSchema.shape, + ...omniaHaDiscoverySchema.shape, + ...telemetryConfigStorageSchema.shape, + ...buildStreamGitLabSchema.shape, + showCredentials: z.boolean().optional(), + user_registry_name: z.string().optional(), + user_registry_username: z.string().optional(), + user_registry_password: z.string().optional(), +}); + +export type WizardFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts new file mode 100644 index 0000000000..cbbfc02e19 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import { HOST_PORT_PATTERN, CERT_PATH_PATTERN, KEY_PATH_PATTERN } from './common'; + +// Shared schemas for repository entries +export const repoEntrySchema = z.object({ + url: z.string().url('Repository URL must be a valid URL'), + name: z.string().min(1, 'Repository name is required').optional(), + gpgkey: z.string().optional(), + policy: z.union([z.enum(['always', 'partial'] as const, { message: 'Policy must be either "always" or "partial"' }), z.literal('')]).optional(), + sslcacert: z.string().regex(CERT_PATH_PATTERN, 'SSL CA certificate must be a .crt file or empty').optional(), + sslclientkey: z.string().regex(KEY_PATH_PATTERN, 'SSL client key must be a .key file or empty').optional(), + sslclientcert: z.string().regex(CERT_PATH_PATTERN, 'SSL client certificate must be a .crt file or empty').optional(), + caching: z.union([z.enum(['true', 'false'] as const, { message: 'Caching must be either "true" or "false"' }), z.literal('')]).optional(), +}).refine( + (data) => { + // gpgkey is optional, but if provided must be a valid URL + if (data.gpgkey && data.gpgkey.trim() !== '') { + try { + new URL(data.gpgkey); + return true; + } catch { + return false; + } + } + return true; + }, + { + message: 'GPG key URL must be a valid URL when provided', + path: ['gpgkey'], + } +); + +export const omniaRepoEntrySchema = z.object({ + url: z.string().url('Repository URL must be a valid URL'), + name: z.string().min(1, 'Repository name is required').optional(), + gpgkey: z.string().optional(), + policy: z.union([z.enum(['always', 'partial'] as const, { message: 'Policy must be either "always" or "partial"' }), z.literal('')]).optional(), + caching: z.union([z.enum(['true', 'false'] as const, { message: 'Caching must be either "true" or "false"' }), z.literal('')]).optional(), +}).refine( + (data) => { + // gpgkey is optional, but if provided must be a valid URL + if (data.gpgkey && data.gpgkey.trim() !== '') { + try { + new URL(data.gpgkey); + return true; + } catch { + return false; + } + } + return true; + }, + { + message: 'GPG key URL must be a valid URL when provided', + path: ['gpgkey'], + } +); + +// User registry entry schema with validation +export const userRegistryEntrySchema = z.object({ + host: z.string().regex(HOST_PORT_PATTERN, 'Registry host must be in format "IP:port" or "hostname:port"'), + cert_path: z.string().regex(CERT_PATH_PATTERN, 'Certificate path must be a .crt file or empty'), + key_path: z.string().regex(KEY_PATH_PATTERN, 'Key path must be a .key file or empty'), +}).refine( + (data) => { + // If host uses HTTPS, cert_path and key_path are required + if (data.host && data.host.startsWith('https://')) { + return !!data.cert_path && !!data.key_path; + } + return true; + }, + { + message: 'When using HTTPS, both certificate path and key path are required', + path: ['cert_path'], + } +).refine( + (data) => { + // If host uses HTTPS, cert_path and key_path are required + if (data.host && data.host.startsWith('https://')) { + return !!data.cert_path && !!data.key_path; + } + return true; + }, + { + message: 'When using HTTPS, both certificate path and key path are required', + path: ['key_path'], + } +); + +export function getLocalRepoOsSchema(osType: 'rhel' | 'ubuntu') { + return z.object({ + // User Registry Credential fields (array for field array usage) + user_registry_credential: z.array(z.object({ + name: z.string().min(1, 'User registry name is required'), + username: z.string().optional(), + password: z.string().optional(), + })).default([]), + // Local Repo Config fields (array for field array usage) + user_registry: z.array(userRegistryEntrySchema).optional(), + user_repo_url_x86_64: z.array(repoEntrySchema).optional(), + user_repo_url_aarch64: z.array(repoEntrySchema).optional(), + additional_repos_x86_64: z.array(repoEntrySchema).optional(), + additional_repos_aarch64: z.array(repoEntrySchema).optional(), + // OS-specific repo keys + [`${osType}_os_url_x86_64`]: z.array(repoEntrySchema).optional(), + [`${osType}_os_url_aarch64`]: z.array(repoEntrySchema).optional(), + [`omnia_repo_url_${osType}_x86_64`]: z.array(omniaRepoEntrySchema).optional(), + [`omnia_repo_url_${osType}_aarch64`]: z.array(omniaRepoEntrySchema).optional(), + [`${osType}_subscription_repo_config_x86_64`]: z.array(repoEntrySchema).optional(), + [`${osType}_subscription_repo_config_aarch64`]: z.array(repoEntrySchema).optional(), + }); +} + +// Convenience schemas for the existing wizard and new management page +export const localRepoUserRegistrySchema = getLocalRepoOsSchema('rhel'); +export const localRepoUbuntuSchema = getLocalRepoOsSchema('ubuntu'); + +export type LocalRepoUserRegistryFormData = z.infer; +export type LocalRepoUbuntuFormData = z.infer; +export type LocalRepoOsFormData = LocalRepoUserRegistryFormData | LocalRepoUbuntuFormData; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts new file mode 100644 index 0000000000..d8d0a8ba32 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; +import { POD_EXTERNAL_IP_RANGE_PATTERN, CIDR_PATTERN, GIGABYTES_PATTERN, IPV4_PATTERN, SLURM_CONFIG_FILE_NAMES } from './common'; +import * as yaml from 'js-yaml'; + +// Schema for a single node hardware defaults entry +const nodeHardwareDefaultsEntry = z.object({ + group_name: z.string().min(1, 'Group name is required'), + sockets: z.coerce.number().int().min(1, 'Must be at least 1'), + cores_per_socket: z.coerce.number().int().min(1, 'Must be at least 1'), + threads_per_core: z.coerce.number().int().min(1, 'Must be at least 1'), + real_memory: z.coerce.number().int().min(1, 'Must be at least 1'), + gres: z.string().default(''), +}); + +// Schema for a single config source entry +const configSourceEntrySchema = z.object({ + name: z.enum(SLURM_CONFIG_FILE_NAMES), + mode: z.enum(['yaml', 'filepath']), + yaml_content: z.string() + .refine( + (val) => { + if (!val || val.trim() === '') return true; + try { yaml.load(val); return true; } catch { return false; } + }, + { message: 'Must be valid YAML' } + ) + .default(''), + file_path: z.string().default(''), +}).refine( + (entry) => { + if (entry.mode === 'filepath') { + return entry.file_path.length > 0; + } + if (entry.mode === 'yaml') { + return entry.yaml_content.trim().length > 0; + } + return true; + }, + { message: 'Content is required for the selected mode' } +); + +// Combined schema for Omnia Config, High Availability, and Discovery +export const omniaHaDiscoverySchema = z.object({ + // Omnia Config - Slurm Cluster + slurm_cluster: z.array(z.object({ + cluster_name: z.string().min(1, 'Slurm cluster name is required'), + nfs_storage_name: z.string().min(1, 'NFS storage name is required'), + vast_storage_name: z.string().min(1, 'VAST storage name is required'), + skip_merge: z.boolean().default(false), + node_discovery_mode: z.enum(['homogeneous', 'heterogeneous']).default('heterogeneous'), + node_hardware_defaults: z.array(nodeHardwareDefaultsEntry).default([]), + config_sources: z.array(configSourceEntrySchema).default([]), + })).min(1, 'At least one Slurm cluster configuration is required'), + + // Omnia Config - Service K8s Cluster + service_k8s_cluster: z.array(z.object({ + cluster_name: z.string().min(1, 'K8s cluster name is required'), + deployment: z.coerce.boolean().default(false), + etcd_on_local_disk: z.boolean().default(false), + k8s_cni: z.enum(['calico', 'flannel']).default('calico'), + pod_external_ip_range: z.string().regex(POD_EXTERNAL_IP_RANGE_PATTERN, 'Pod external IP range must be a valid CIDR or IP range'), + k8s_service_addresses: z.string().regex(CIDR_PATTERN, 'Service addresses must be a valid CIDR').default('10.233.0.0/18'), + k8s_pod_network_cidr: z.string().regex(CIDR_PATTERN, 'Pod network CIDR must be a valid CIDR').default('10.233.64.0/18'), + nfs_storage_name: z.string().optional(), + k8s_crio_storage_size: z.string().regex(GIGABYTES_PATTERN, 'CRI-O storage size must be in format like 10G, 15G').default('20G'), + csi_powerscale_driver_secret_file_path: z.string().optional(), + csi_powerscale_driver_values_file_path: z.string().optional(), + })).min(1, 'At least one K8s cluster configuration is required') + .refine( + (clusters) => clusters.filter((c) => c.deployment === true).length === 1, + { message: 'Exactly one K8s cluster must have deployment enabled' } + ) + .superRefine((clusters, ctx) => { + clusters.forEach((cluster, index) => { + if ( + cluster.csi_powerscale_driver_secret_file_path && + cluster.csi_powerscale_driver_secret_file_path.length > 0 && + (!cluster.csi_powerscale_driver_values_file_path || + cluster.csi_powerscale_driver_values_file_path.length === 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Values file path is required when secret file path is provided', + path: [index, 'csi_powerscale_driver_values_file_path'], + }); + } + }); + }), + + // High Availability - Service K8s Cluster HA (Optional) + enable_ha: z.boolean().optional(), + service_k8s_cluster_ha: z.array(z.object({ + cluster_name: z.string().optional(), + enable_k8s_ha: z.boolean(), + virtual_ip_address: z.string().optional(), + })).optional(), + + // Security Config + enable_security_config: z.boolean().optional(), + security_config: z.object({ + ldap_connection_type: z.enum(['TLS', 'SSL']).default('TLS'), + }).optional(), +}).superRefine((data, ctx) => { + if (data.enable_ha) { + if (!data.service_k8s_cluster_ha || data.service_k8s_cluster_ha.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one HA cluster is required when HA is enabled', + path: ['service_k8s_cluster_ha'], + }); + } + data.service_k8s_cluster_ha?.forEach((ha, index) => { + if (!ha.cluster_name || ha.cluster_name.trim() === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'K8s cluster name is required', + path: ['service_k8s_cluster_ha', index, 'cluster_name'], + }); + } + if (!ha.virtual_ip_address || !IPV4_PATTERN.test(ha.virtual_ip_address)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Virtual IP address must be a valid IPv4 address', + path: ['service_k8s_cluster_ha', index, 'virtual_ip_address'], + }); + } + }); + } +}); + +// Strict schema for L2 validation when HA is enabled +export const serviceK8sClusterHaSchema = z.array(z.object({ + cluster_name: z.string().min(1, 'K8s cluster name is required'), + enable_k8s_ha: z.boolean(), + virtual_ip_address: z.string().regex(IPV4_PATTERN, 'Virtual IP address must be a valid IPv4 address'), +})); + +export type OmniaHaDiscoveryFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts new file mode 100644 index 0000000000..b66f3fc98f --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +// PXE Mapping Row schema +const pxeMappingRowSchema = z.object({ + FUNCTIONAL_GROUP_NAME: z.string().regex( + /^[A-Za-z][A-Za-z0-9_]{1,}_(x86_64|aarch64)$/, + 'Functional group name must start with a letter, contain only letters/numbers/underscores, and end with _x86_64 or _aarch64' + ), + GROUP_NAME: z.string().optional().default(''), + SERVICE_TAG: z.string().optional().default(''), + PARENT_SERVICE_TAG: z.string().optional().default(''), + HOSTNAME: z.string().optional().default(''), + ADMIN_MAC: z.string().optional().default(''), + ADMIN_IP: z.string().optional().default(''), + BMC_MAC: z.string().optional().default(''), + BMC_IP: z.string().optional().default(''), + IB_NIC_NAME: z.string().optional().default(''), + IB_IP: z.string().optional().default(''), +}); + +// PXE Functional Groups Schema (based on provision_config.yml) +export const pxeFunctionalGroupsSchema = z.object({ + pxe_mapping_file_path: z.string().min(1, 'PXE mapping file path is required'), + pxe_mapping_data: z.array(pxeMappingRowSchema) + .min(1, 'At least one PXE mapping row is required'), + language: z.string().default('en_US.UTF-8').transform(() => 'en_US.UTF-8' as const), + default_lease_time: z.coerce + .number() + .int('Lease time must be a whole number') + .min(21600, 'Lease time must be at least 21600 seconds (6 hours)') + .max(31536000, 'Lease time must be at most 31536000 seconds (1 year)') + .transform(String) + .default('86400'), + dns_enabled: z.boolean().optional(), + kernel_version_override: z.string().optional(), + additional_cloud_init_config_file: z.union([ + z.literal(''), + z.string().regex(/\.(yml|yaml)$/, 'File path must end with .yml or .yaml'), + ]).optional(), +}); + +export type PxeFunctionalGroupsFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts new file mode 100644 index 0000000000..aef1372a72 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts @@ -0,0 +1,290 @@ +import { z } from 'zod'; +import { IPV4_PATTERN, URL_PATTERN } from './common'; + +// --- Reusable patterns --- +const MOUNT_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; +const ABSOLUTE_PATH_PATTERN = /^\/\S+$/; +const DUMP_FREQ_PATTERN = /^[0-2]$/; +const FSCK_PASS_PATTERN = /^[0-9]$/; +const OCTAL_MODE_PATTERN = /^[0-7]{3,4}$/; +const HEX_PATTERN = /^[a-fA-F0-9]+$/; +const IQN_PATTERN = /^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9.-]+:[a-zA-Z0-9._:-]+$/; +const SWAP_SIZE_PATTERN = /^(auto|\d+|[1-9]\d*[GMK])$/; +const SWAP_MAXSIZE_PATTERN = /^(\d+|[1-9]\d*[GMK])$/; + +const FS_TYPES = ['auto','ext2','ext3','ext4','xfs','nfs','nfs4','cifs', + 'tmpfs','cephfs','vfat','ntfs','none','fuse.s3fs'] as const; +const PV_FS_TYPES = ['xfs','ext4','ext3','ext2','nfs','nfs4','cifs','ntfs','auto'] as const; +const NODE_KEY_VALUES = ['local_hostname','local_ipv4','instance_id'] as const; + +// --- Permissions sub-schema --- +const permissionsSchema = z.object({ + owner: z.string().default('root'), + group: z.string().default('root'), + mode: z.string().default('0755').refine(val => !val || val === '' || OCTAL_MODE_PATTERN.test(val), { message: 'Octal 3-4 digits, e.g. 0755' }), +}).optional(); + +// --- Helper to convert comma-separated string to array --- +const commaStringToArray = (value: any) => { + if (typeof value === 'string') { + return value.split(',').map(s => s.trim()).filter(s => s.length > 0); + } + if (Array.isArray(value)) { + return value; + } + return []; +}; + +// --- Mount entry (loose for disabled state; strict validation applied via superRefine when _ui_showMounts is true) +const mountEntrySchema = z.object({ + name: z.string().optional(), + source: z.string().optional(), + mount_point: z.string().optional(), + mount_params: z.string().optional(), // profile name reference + fs_type: z.enum(FS_TYPES).or(z.literal('')).optional(), + mnt_opts: z.string().optional(), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), + mount_on_oim: z.boolean().default(false), + node_key: z.union([z.enum(NODE_KEY_VALUES), z.literal('')]).default('').optional().transform(val => val === '' ? undefined : val), + node_mount_point: z.any().transform(commaStringToArray).pipe(z.array(z.string().regex(ABSOLUTE_PATH_PATTERN, 'Absolute path'))).optional(), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string())).optional(), + groups: z.any().transform(commaStringToArray).pipe(z.array(z.string())).optional(), + permissions: permissionsSchema, +}); + +// Strict mount entry schema for validation when Mounts is enabled +const strictMountEntrySchema = z.object({ + name: z.string().regex(MOUNT_NAME_PATTERN, 'Alphanumeric/underscore/dash, 1-64 chars'), + source: z.string().min(1, 'Source is required'), + mount_point: z.string().regex(ABSOLUTE_PATH_PATTERN, 'Must be an absolute path'), + mount_params: z.string().optional(), + fs_type: z.enum(FS_TYPES).or(z.literal('')).optional(), + mnt_opts: z.string().optional(), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), + mount_on_oim: z.boolean().default(false), + node_key: z.union([z.enum(NODE_KEY_VALUES), z.literal('')]).default('').optional().transform(val => val === '' ? undefined : val), + node_mount_point: z.any().transform(commaStringToArray).pipe(z.array(z.string().regex(ABSOLUTE_PATH_PATTERN, 'Absolute path'))).optional(), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string().min(1))).optional(), + groups: z.any().transform(commaStringToArray).pipe(z.array(z.string().min(1))).optional(), + permissions: permissionsSchema, +}) +.refine(d => !(d.functional_group_prefix?.length && d.groups?.length), + { message: 'functional_group_prefix and groups are mutually exclusive', path: ['groups'] }) +.refine(d => (d.functional_group_prefix?.length ?? 0) > 0 || (d.groups?.length ?? 0) > 0, + { message: 'Either functional_group_prefix or groups is required', path: ['functional_group_prefix'] }) +.refine(d => !d.node_key || (d.node_mount_point && d.node_mount_point.length > 0), + { message: 'node_mount_point is required when node_key is set', path: ['node_mount_point'] }); + +// --- Mount params profile --- +const mountParamProfileSchema = z.object({ + fs_type: z.enum(FS_TYPES), // mandatory in profile + mnt_opts: z.string().min(1, 'Mount options required'), // mandatory in profile + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), +}); + +// --- Mount params profile entry (loose for disabled state; strict validation applied via superRefine when _ui_showMountParams is true) +const mountParamProfileEntrySchema = z.object({ + profile_name: z.string().optional(), + fs_type: z.enum(FS_TYPES).optional(), + mnt_opts: z.string().optional(), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), +}); + +// Strict mount params profile entry schema for validation when Mount Params is enabled +const strictMountParamProfileEntrySchema = z.object({ + profile_name: z.string().min(1, 'Profile name is required'), + fs_type: z.enum(FS_TYPES), + mnt_opts: z.string().min(1, 'Mount options required'), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), +}); + +// --- PowerVault entry (loose for disabled state; strict validation applied via superRefine when _ui_showPowerVault is true) +const powervaultEntrySchema = z.object({ + name: z.string().optional(), + ip: z.any().transform(commaStringToArray).pipe(z.array(z.string())).optional(), + port: z.coerce.number().optional().refine(val => val === undefined || val === null || val === 0 || (typeof val === 'number' && val >= 1 && val <= 65535), { message: 'Port must be between 1 and 65535' }).default(3260), + iscsi_initiator: z.string().optional(), + volume_id: z.string().optional(), + mount_point: z.string().optional(), + mount_params: z.string().optional(), + fs_type: z.enum(PV_FS_TYPES).or(z.literal('')).optional(), + mnt_opts: z.string().optional(), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), + node_key: z.union([z.enum(NODE_KEY_VALUES), z.literal('')]).default('').optional().transform(val => val === '' ? undefined : val), + node_mount_point: z.any().transform(commaStringToArray).pipe(z.array(z.string().regex(ABSOLUTE_PATH_PATTERN))).optional(), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string())).optional(), + permissions: permissionsSchema, +}); + +// Strict PowerVault entry schema for validation when PowerVault is enabled +const strictPowervaultEntrySchema = z.object({ + name: z.string().regex(MOUNT_NAME_PATTERN, 'Alphanumeric/underscore/dash, 1-64 chars'), + ip: z.any().transform(commaStringToArray).pipe(z.array(z.string().regex(IPV4_PATTERN)).min(1, 'At least 1 IP required')), + port: z.coerce.number().optional().refine(val => val === undefined || val === null || val === 0 || (typeof val === 'number' && val >= 1 && val <= 65535), { message: 'Port must be between 1 and 65535' }).default(3260), + iscsi_initiator: z.string().regex(IQN_PATTERN, 'IQN format: iqn.YYYY-MM.domain:id'), + volume_id: z.string().regex(HEX_PATTERN, 'Hex string'), + mount_point: z.string().regex(ABSOLUTE_PATH_PATTERN, 'Absolute path'), + mount_params: z.string().optional(), + fs_type: z.enum(PV_FS_TYPES).or(z.literal('')).optional(), + mnt_opts: z.string().optional(), + dump_freq: z.string().optional().refine(val => !val || val === '' || DUMP_FREQ_PATTERN.test(val), { message: 'Must be 0, 1, or 2' }), + fsck_pass: z.string().optional().refine(val => !val || val === '' || FSCK_PASS_PATTERN.test(val), { message: 'Must be 0-9' }), + node_key: z.union([z.enum(NODE_KEY_VALUES), z.literal('')]).default('').optional().transform(val => val === '' ? undefined : val), + node_mount_point: z.any().transform(commaStringToArray).pipe(z.array(z.string().regex(ABSOLUTE_PATH_PATTERN))).optional(), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string().min(1))).refine(d => d.length > 0, { message: 'Functional Group Prefix is required' }), + permissions: permissionsSchema, +}) +.refine(d => !d.node_key || (d.node_mount_point && d.node_mount_point.length > 0), + { message: 'node_mount_point required when node_key is set', path: ['node_mount_point'] }); + +// --- Swap entry (loose for disabled state; strict validation applied via superRefine when _ui_showSwap is true) +const swapEntrySchema = z.object({ + name: z.string().optional(), + filename: z.string().optional(), + size: z.string().optional(), + maxsize: z.string().optional(), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string())).optional(), +}); + +// Strict Swap entry schema for validation when Swap is enabled +const strictSwapEntrySchema = z.object({ + name: z.string().optional().refine(val => !val || val === '' || MOUNT_NAME_PATTERN.test(val), { message: 'Alphanumeric/underscore/dash, 1-64 chars' }), + filename: z.string().regex(ABSOLUTE_PATH_PATTERN, 'Absolute path to swap file'), + size: z.string().regex(SWAP_SIZE_PATTERN, '"auto", byte integer, or human-readable e.g. "2G"'), + maxsize: z.string().optional().refine(val => !val || val === '' || SWAP_MAXSIZE_PATTERN.test(val), { message: 'Byte integer or human-readable e.g. "4G"' }), + functional_group_prefix: z.any().transform(commaStringToArray).pipe(z.array(z.string().min(1))).optional(), +}) +.refine(d => (d.functional_group_prefix?.length ?? 0) > 0, + { message: 'functional_group_prefix is required', path: ['functional_group_prefix'] }) +.refine(d => d.size !== 'auto' || (d.maxsize && d.maxsize.length > 0), + { message: 'maxsize is required when size is "auto"', path: ['maxsize'] }); + +// --- S3 configuration (always required; strict validation applied via superRefine) +const s3ConfigSchema = z.object({ + provider: z.enum(['powerscale', 'minio']).default('powerscale'), + endpoint_url: z.string().optional().or(z.literal('')), +}); + +// Strict S3 configuration schema for validation when S3 is enabled +const strictS3ConfigSchema = z.object({ + provider: z.enum(['powerscale', 'minio']).default('powerscale'), + endpoint_url: z.string().regex(URL_PATTERN, 'Must be a valid URL (e.g., https://10.43.1.11:9021)').optional().or(z.literal('')), +}) +.refine(d => d.provider !== 'powerscale' || (d.endpoint_url && d.endpoint_url.length > 0), + { message: 'endpoint_url is required when provider is powerscale', path: ['endpoint_url'] }); + +// --- Top-level schema --- +export const storageConfigSchema = z.object({ + mounts: z.array(mountEntrySchema).optional().default([]), + mount_params: z.record(z.string(), mountParamProfileSchema).optional().default({}), + _mount_params_entries: z.array(mountParamProfileEntrySchema).optional().default([]), // UI helper array + powervault_config: z.array(powervaultEntrySchema).optional().default([]), + swap: z.array(swapEntrySchema).optional().default([]), + s3_configurations: s3ConfigSchema.optional().default({ provider: 'powerscale', endpoint_url: '' }), + _ui_showMounts: z.boolean().optional(), + _ui_showMountParams: z.boolean().optional(), + _ui_showPowerVault: z.boolean().optional(), + _ui_showSwap: z.boolean().optional(), +}).superRefine((data, ctx) => { + if (data._ui_showMounts) { + data.mounts?.forEach((mount, index) => { + const result = strictMountEntrySchema.safeParse(mount); + if (!result.success && result.error) { + result.error.issues.forEach((issue) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ['mounts', index, ...issue.path], + }); + }); + } + }); + } + + if (data._ui_showMountParams) { + data._mount_params_entries?.forEach((entry, index) => { + const result = strictMountParamProfileEntrySchema.safeParse(entry); + if (!result.success && result.error) { + result.error.issues.forEach((issue) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ['_mount_params_entries', index, ...issue.path], + }); + }); + } + }); + } + + if (data._ui_showPowerVault) { + data.powervault_config?.forEach((entry, index) => { + const result = strictPowervaultEntrySchema.safeParse(entry); + if (!result.success && result.error) { + result.error.issues.forEach((issue) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ['powervault_config', index, ...issue.path], + }); + }); + } + }); + } + + if (data._ui_showSwap) { + data.swap?.forEach((entry, index) => { + const result = strictSwapEntrySchema.safeParse(entry); + if (!result.success && result.error) { + result.error.issues.forEach((issue) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ['swap', index, ...issue.path], + }); + }); + } + }); + } + + if (data.s3_configurations) { + const result = strictS3ConfigSchema.safeParse(data.s3_configurations); + if (!result.success && result.error) { + result.error.issues.forEach((issue) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: ['s3_configurations', ...issue.path], + }); + }); + } + } + + // Storage Configuration is mandatory: at least one option must be configured + const hasConfiguredStorage = + data._ui_showMounts || + data._ui_showMountParams || + data._ui_showPowerVault || + data._ui_showSwap || + data.s3_configurations != null || + data.mounts?.some((m: any) => m.name?.trim() || m.source?.trim() || m.mount_point?.trim()) || + data._mount_params_entries?.some((e: any) => e.profile_name?.trim()) || + Object.keys(data.mount_params || {}).length > 0 || + data.powervault_config?.some((p: any) => p.name?.trim()) || + data.swap?.some((s: any) => s.filename?.trim()); + + if (!hasConfiguredStorage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Please configure at least one storage option (Mounts, Mount Params, PowerVault, Swap, or S3)', + path: ['storage'], + }); + } +}); + +export type StorageConfigFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts b/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts new file mode 100644 index 0000000000..81533bb826 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts @@ -0,0 +1,321 @@ +import { z } from 'zod'; +import { STORAGE_SIZE_PATTERN, SCRAPE_DURATION_PATTERN, CPU_RESOURCE_PATTERN, MEMORY_RESOURCE_PATTERN, YAML_FILE_PATTERN, IPV4_OR_HOSTNAME_PATTERN, CERT_PATH_PATTERN } from './common'; + +// Helper to parse duration strings like "30s", "1m", "1h" into seconds +const parseDuration = (duration: string): number => { + const match = duration.match(/^(\d+)([smh])$/); + if (!match) return 0; + const value = parseInt(match[1], 10); + const multipliers: Record = { s: 1, m: 60, h: 3600 }; + return value * multipliers[match[2]]; +}; + +// Helper to validate scrape timeout <= scrape interval +const scrapeTimeoutRefine = ( + data: { scrape_interval?: string; scrape_timeout?: string } +) => { + if (data.scrape_interval && data.scrape_timeout) { + return parseDuration(data.scrape_timeout) <= parseDuration(data.scrape_interval); + } + return true; +}; + +const SCRAPE_TIMEOUT_MESSAGE = { + message: 'Scrape timeout must be less than or equal to scrape interval', + path: ['scrape_timeout'], +}; + +// Resource schema for CPU and memory limits +const resourceSchema = z.object({ + requests: z.object({ + cpu: z.string().regex(CPU_RESOURCE_PATTERN, 'CPU must be in format like 100m, 500m, 1, or 2'), + memory: z.string().regex(MEMORY_RESOURCE_PATTERN, 'Memory must be in format like 256Mi, 512Mi, or 1Gi'), + }), + limits: z.object({ + cpu: z.string().regex(CPU_RESOURCE_PATTERN, 'CPU must be in format like 100m, 500m, 1, or 2'), + memory: z.string().regex(MEMORY_RESOURCE_PATTERN, 'Memory must be in format like 256Mi, 512Mi, or 1Gi'), + }), +}); + +// Component with replicas and resources +const componentWithReplicasSchema = z.object({ + replicas: z.coerce.number().int().min(1), + resources: resourceSchema, +}); + +// Component with replicas, pvc_size, and resources +const componentWithPvcSchema = z.object({ + replicas: z.coerce.number().int().min(1), + pvc_size: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').optional(), + resources: resourceSchema, +}); + +// Combined schema for Telemetry Config and Telemetry Storage +export const telemetryConfigStorageSchema = z.object({ + // Telemetry Sources (from telemetry_config) + telemetry_sources: z.object({ + idrac: z.object({ + metrics_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['victoria_metrics', 'kafka'])).default([]), + }), + ldms: z.object({ + metrics_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['kafka'])).max(1).default([]), + }), + dcgm: z.object({ + metrics_enabled: z.boolean().default(false), + }), + powerscale: z.object({ + metrics_enabled: z.boolean().default(false), + logs_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['victoria_metrics', 'victoria_logs'])).default([]), + }), + ufm: z.object({ + metrics_enabled: z.boolean().default(false), + logs_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['victoria_metrics', 'victoria_logs'])).default([]), + }), + vast: z.object({ + metrics_enabled: z.boolean().default(false), + logs_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['victoria_metrics', 'victoria_logs'])).default([]), + }), + ome: z.object({ + metrics_enabled: z.boolean().default(false), + logs_enabled: z.boolean().default(false), + collection_targets: z.array(z.enum(['kafka'])).max(1).default([]), + }), + }), + telemetry_bridges: z.object({ + vector_ldms: z.object({ + metrics_enabled: z.boolean().default(false), + }).optional(), + vector_ome: z.object({ + metrics_enabled: z.boolean().default(false), + logs_enabled: z.boolean().default(false), + ome_identifier: z.string().min(1).default('ome'), + }).optional(), + }).optional(), + telemetry_sinks: z.object({ + victoria_metrics: z.object({ + persistence_size: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').default('8Gi'), + retention_period: z.coerce.number().min(24, 'Retention period must be at least 24 hours').default(168), + additional_metric_remote_write_endpoints: z.array(z.object({ + url: z.string().url('URL must be valid'), + tls_insecure_skip_verify: z.boolean().default(false), + })).default([]), + }).optional(), + victoria_logs: z.object({ + storage_size: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').default('8Gi'), + retention_period: z.coerce.number().min(24, 'Retention period must be at least 24 hours').default(168), + additional_log_write_endpoints: z.array(z.object({ + url: z.string().url('URL must be valid'), + tls_insecure_skip_verify: z.boolean().default(false), + })).default([]), + }).optional(), + kafka: z.object({ + persistence_size: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').default('8Gi'), + log_retention_hours: z.coerce.number().min(1, 'Log retention must be at least 1 hour').default(168), + log_retention_bytes: z.coerce.number().default(-1), + log_segment_bytes: z.coerce.number().min(1).default(1073741824), + topic_partitions: z.object({ + idrac: z.coerce.number().min(1).max(100).default(1), + ldms: z.coerce.number().min(1).max(100).default(2), + }), + }).optional(), + }).optional(), + idrac_telemetry_configurations: z.object({ + mysqldb_storage: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').default('1Gi'), + }).optional(), + ldms_configurations: z.object({ + agg_port: z.coerce.number().min(6001).max(6100).default(6001), + store_port: z.coerce.number().min(6001).max(6100).default(6001), + sampler_port: z.coerce.number().min(10001).max(10100).default(10001), + sampler_plugins: z.array(z.object({ + plugin_name: z.string().min(1, 'Plugin name is required'), + config_parameters: z.string().optional(), + activation_parameters: z.string().regex(/^interval=[1-9][0-9]*(\s+offset=[0-9]+)?$/, 'Must be in format interval= or interval= offset='), + })).refine( + (plugins) => { + // If plugin_name is slurm_sampler, config_parameters is required and must contain specific fields + for (const plugin of plugins) { + if (plugin.plugin_name === 'slurm_sampler') { + if (!plugin.config_parameters) { + return false; + } + // Must contain component_id, stream, job_count, and task_count + const hasComponentId = /component_id=/.test(plugin.config_parameters); + const hasStream = /stream=/.test(plugin.config_parameters); + const hasJobCount = /job_count=/.test(plugin.config_parameters); + const hasTaskCount = /task_count=/.test(plugin.config_parameters); + if (!hasComponentId || !hasStream || !hasJobCount || !hasTaskCount) { + return false; + } + } + // If plugin_name starts with procnetdev, config_parameters (if provided) must match ifaces pattern + if (plugin.plugin_name.startsWith('procnetdev') && plugin.config_parameters) { + const ifacesMatch = /ifaces=[a-zA-Z0-9_,]+/.test(plugin.config_parameters); + if (!ifacesMatch) { + return false; + } + } + } + return true; + }, + { message: 'slurm_sampler requires config_parameters with component_id, stream, job_count, and task_count; procnetdev* config_parameters must include ifaces if provided' } + ), + }).optional(), + powerscale_configurations: z.object({ + otel_collector_storage_size: z.string().regex(STORAGE_SIZE_PATTERN, 'Storage size must be in format like 8Gi, 512Mi').default('5Gi'), + csm_observability_values_file_path: z.union([z.literal(''), z.string().regex(YAML_FILE_PATTERN, 'File must end with .yml or .yaml')]).optional(), + }).optional(), + ufm_configuration: z.object({ + ufm_endpoint: z.union([z.literal(''), z.string().regex(IPV4_OR_HOSTNAME_PATTERN, 'UFM endpoint must be a valid IPv4 address or hostname')]).optional(), + ufm_metrics_port: z.coerce.number().min(1).max(65535).default(9001), + scrape_interval: z.string().regex(SCRAPE_DURATION_PATTERN, 'Must be in format like 30s, 5m, 1h').default('30s'), + scrape_timeout: z.string().regex(SCRAPE_DURATION_PATTERN, 'Must be in format like 30s, 5m, 1h').default('15s'), + tls_mode: z.enum(['self_signed', 'ca_signed']).default('self_signed'), + ufm_ca_cert_path: z.string().regex(CERT_PATH_PATTERN, 'CA cert path must be a valid .crt file path or empty').default(''), + auth_mode: z.enum(['basic', 'none']).default('basic'), + }) + .refine(scrapeTimeoutRefine, SCRAPE_TIMEOUT_MESSAGE) + .refine( + (data) => { + if (data.tls_mode === 'ca_signed') { + return !!data.ufm_ca_cert_path && data.ufm_ca_cert_path.length > 0; + } + return true; + }, + { + message: 'CA cert path is required when TLS mode is CA-signed', + path: ['ufm_ca_cert_path'], + } + ) + .optional(), + vast_configuration: z.object({ + vast_endpoint: z.union([z.literal(''), z.string().regex(IPV4_OR_HOSTNAME_PATTERN, 'VAST endpoint must be a valid IPv4 address or hostname')]).optional(), + vast_metrics_port: z.coerce.number().min(1).max(65535).default(443), + metrics_path: z.string().default('/api/prometheusmetrics/all'), + scrape_interval: z.string().regex(SCRAPE_DURATION_PATTERN, 'Must be in format like 30s, 5m, 1h').default('30s'), + scrape_timeout: z.string().regex(SCRAPE_DURATION_PATTERN, 'Must be in format like 30s, 5m, 1h').default('15s'), + tls_mode: z.enum(['self_signed', 'ca_signed']).default('self_signed'), + vast_ca_cert_path: z.string().regex(CERT_PATH_PATTERN, 'CA cert path must be a valid .crt file path or empty').default(''), + auth_mode: z.enum(['basic', 'none']).default('basic'), + }) + .refine(scrapeTimeoutRefine, SCRAPE_TIMEOUT_MESSAGE) + .refine( + (data) => { + if (data.tls_mode === 'ca_signed') { + return !!data.vast_ca_cert_path && data.vast_ca_cert_path.length > 0; + } + return true; + }, + { + message: 'CA cert path is required when TLS mode is CA-signed', + path: ['vast_ca_cert_path'], + } + ) + .optional(), + + // Telemetry Storage (from telemetry_storage_config) + victoria_cluster_storage: z.object({ + vmstorage: componentWithReplicasSchema, + vminsert: componentWithReplicasSchema, + vmselect: componentWithReplicasSchema, + vmagent: componentWithReplicasSchema, + }).optional(), + + victoria_logs_cluster_storage: z.object({ + vlstorage: componentWithReplicasSchema, + vlinsert: componentWithReplicasSchema, + vlselect: componentWithReplicasSchema, + vlagent: componentWithPvcSchema, + }).optional(), + + vector_storage: z.object({ + ldms: componentWithReplicasSchema, + ome: componentWithReplicasSchema, + vlagent_vector: componentWithPvcSchema, + vmagent_vector: componentWithPvcSchema, + }).optional(), + + csi_volume_exporter_storage: z.object({ + resources: resourceSchema, + }).optional(), + + csm_metrics_powerscale_storage: z.object({ + resources: resourceSchema, + }).optional(), + + idrac_telemetry_storage: z.object({ + mysqldb: z.object({ + resources: resourceSchema, + }), + activemq: z.object({ + resources: resourceSchema, + }), + receiver: z.object({ + resources: resourceSchema, + }), + kafka_pump: z.object({ + resources: resourceSchema, + }), + victoria_pump: z.object({ + resources: resourceSchema, + }), + }).optional(), + + kafka_storage: z.object({ + kafka: z.object({ + resources: resourceSchema, + }), + entity_operator: z.object({ + user_operator: z.object({ + resources: resourceSchema, + }), + }), + }).optional(), +}).refine( + (data) => { + const powerscale = data.telemetry_sources?.powerscale; + if (powerscale?.metrics_enabled || powerscale?.logs_enabled) { + const path = data.powerscale_configurations?.csm_observability_values_file_path || ''; + return path.length > 0 && YAML_FILE_PATTERN.test(path); + } + return true; + }, + { + message: 'CSM Observability values file path is required and must end with .yml or .yaml', + path: ['powerscale_configurations', 'csm_observability_values_file_path'], + } +) +.refine( + (data) => { + const ufm = data.telemetry_sources?.ufm; + if (ufm?.metrics_enabled || ufm?.logs_enabled) { + const endpoint = data.ufm_configuration?.ufm_endpoint || ''; + return endpoint.length > 0 && IPV4_OR_HOSTNAME_PATTERN.test(endpoint); + } + return true; + }, + { + message: 'UFM endpoint is required and must be a valid IPv4 address or hostname', + path: ['ufm_configuration', 'ufm_endpoint'], + } +) +.refine( + (data) => { + const vast = data.telemetry_sources?.vast; + if (vast?.metrics_enabled || vast?.logs_enabled) { + const endpoint = data.vast_configuration?.vast_endpoint || ''; + return endpoint.length > 0 && IPV4_OR_HOSTNAME_PATTERN.test(endpoint); + } + return true; + }, + { + message: 'VAST endpoint is required and must be a valid IPv4 address or hostname', + path: ['vast_configuration', 'vast_endpoint'], + } +); + +export type TelemetryConfigStorageFormData = z.infer; diff --git a/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx b/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx new file mode 100644 index 0000000000..3c2a605ad7 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx @@ -0,0 +1,258 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useConfigStore } from '../../configStore'; +import { BuildStreamGitLabFormData, buildStreamGitLabSchema } from '../../schemas'; +import { clearL2ErrorsForStep } from '../../utils/l2Validation'; +import { useFormErrors } from '../../hooks/useFormErrors'; + +export const BuildStreamGitLabStep = () => { + const { updateWizardFields, wizardData, setStepValid, enableBuildStream, enableGitlab } = useConfigStore(); + const validationErrors = useConfigStore((s) => s.validationErrors); + + const { + register, + formState: { errors }, + watch, + setValue, + getValues, + } = useForm({ + // zodResolver type inference conflicts with .default() and .refine() on optional fields. + resolver: zodResolver(buildStreamGitLabSchema) as any, + defaultValues: { + enable_build_stream: typeof wizardData.enable_build_stream === 'boolean' ? wizardData.enable_build_stream : enableBuildStream, + build_stream_host_ip: (wizardData.build_stream_host_ip as string) || '', + build_stream_port: typeof wizardData.build_stream_port === 'number' ? wizardData.build_stream_port : 8010, + aarch64_inventory_host_ip: wizardData.aarch64_inventory_host_ip as string | undefined, + enable_gitlab: typeof wizardData.enable_gitlab === 'boolean' ? wizardData.enable_gitlab : enableGitlab, + // GitLab defaults from input/gitlab_config.yml + gitlab_host: (wizardData.gitlab_host as string) || '', + gitlab_project_name: (wizardData.gitlab_project_name as string) || 'omnia-catalog', + gitlab_project_visibility: (wizardData.gitlab_project_visibility as 'private' | 'internal' | 'public') || 'private', + gitlab_default_branch: (wizardData.gitlab_default_branch as string) || 'main', + gitlab_https_port: typeof wizardData.gitlab_https_port === 'number' ? wizardData.gitlab_https_port : 443, + gitlab_min_storage_gb: typeof wizardData.gitlab_min_storage_gb === 'number' ? wizardData.gitlab_min_storage_gb : 20, + gitlab_min_memory_gb: typeof wizardData.gitlab_min_memory_gb === 'number' ? wizardData.gitlab_min_memory_gb : 4, + gitlab_min_cpu_cores: typeof wizardData.gitlab_min_cpu_cores === 'number' ? wizardData.gitlab_min_cpu_cores : 2, + gitlab_puma_workers: typeof wizardData.gitlab_puma_workers === 'number' ? wizardData.gitlab_puma_workers : 2, + gitlab_sidekiq_concurrency: typeof wizardData.gitlab_sidekiq_concurrency === 'number' ? wizardData.gitlab_sidekiq_concurrency : 10, + }, + mode: 'onTouched', + }); + + const getError = useFormErrors(errors, validationErrors); + + // Sync initial Build Stream/GitLab form values to store immediately so Summary + // validation works even if the user navigates quickly, then keep syncing subsequent changes + useEffect(() => { + const currentValues = getValues(); + updateWizardFields(currentValues as Partial); + }, []); + + // Sync form changes to store and validate step + useEffect(() => { + const currentValues = watch(); + const initialResult = buildStreamGitLabSchema.safeParse(currentValues); + setStepValid(8, initialResult.success); + clearL2ErrorsForStep(initialResult, 'Build Stream & GitLab', useConfigStore.getState); + + let timer: ReturnType; + const subscription = watch((formValues) => { + const result = buildStreamGitLabSchema.safeParse(formValues); + setStepValid(8, result.success); + clearL2ErrorsForStep(result, 'Build Stream & GitLab', useConfigStore.getState); + + clearTimeout(timer); + timer = setTimeout(() => { + updateWizardFields(formValues as Partial); + }, 300); + }); + return () => { clearTimeout(timer); subscription.unsubscribe(); }; + }, [watch, setStepValid, updateWizardFields]); + + // Sync store enable values to form (one-way sync from store to form) + useEffect(() => { + setValue('enable_build_stream', enableBuildStream); + }, [enableBuildStream, setValue]); + + useEffect(() => { + setValue('enable_gitlab', enableGitlab); + }, [enableGitlab, setValue]); + + return ( +
+ {/* Build Stream Configuration */} + {enableBuildStream && ( +
+
+ +
+ +
+
+
+ + + {getError('build_stream_host_ip') && {String(getError('build_stream_host_ip')?.message)}} +
+
+ + +

Default: 8010

+ {getError('build_stream_port') && {String(getError('build_stream_port')?.message)}} +
+
+ + + {getError('aarch64_inventory_host_ip') && {String(getError('aarch64_inventory_host_ip')?.message)}} +
+
+
+
+ )} + + {/* GitLab Configuration */} + {enableGitlab && ( +
+
+ +
+ +
+
+
+ + + {getError('gitlab_host') && {String(getError('gitlab_host')?.message)}} +
+
+ + + {getError('gitlab_project_name') && {String(getError('gitlab_project_name')?.message)}} +
+
+ + + {getError('gitlab_project_visibility') && {String(getError('gitlab_project_visibility')?.message)}} +
+
+ + + {getError('gitlab_default_branch') && {String(getError('gitlab_default_branch')?.message)}} +
+
+ +
+
+ + +

Default: 443

+ {getError('gitlab_https_port') && {String(getError('gitlab_https_port')?.message)}} +
+
+ + +

Default: 20

+ {getError('gitlab_min_storage_gb') && {String(getError('gitlab_min_storage_gb')?.message)}} +
+
+ + +

Default: 4

+ {getError('gitlab_min_memory_gb') && {String(getError('gitlab_min_memory_gb')?.message)}} +
+
+ + +

Default: 2

+ {getError('gitlab_min_cpu_cores') && {String(getError('gitlab_min_cpu_cores')?.message)}} +
+
+ +
+
+ + +

Default: 2

+ {getError('gitlab_puma_workers') && {String(getError('gitlab_puma_workers')?.message)}} +
+
+ + +

Default: 10

+ {getError('gitlab_sidekiq_concurrency') && {String(getError('gitlab_sidekiq_concurrency')?.message)}} +
+
+
+
+ )} +
+ ); +}; diff --git a/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx b/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx new file mode 100644 index 0000000000..45c2f0c012 --- /dev/null +++ b/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx @@ -0,0 +1,139 @@ +import type { UseFormRegister, FieldErrors, Control } from 'react-hook-form'; +import { useFieldArray } from 'react-hook-form'; +import Button from '../../../../components/Button'; +import { CloudInitConfigFormData } from '../../schemas/cloudInitConfig'; +import { useFormErrors } from '../../hooks/useFormErrors'; +import type { ValidationError } from '../../utils/l2Validation'; + +interface CloudInitCommonTabProps { + register: UseFormRegister; + errors: FieldErrors; + control: Control; + validationErrors?: ValidationError[]; +} + +export const CloudInitCommonTab = ({ register, errors, control, validationErrors }: CloudInitCommonTabProps) => { + const getError = useFormErrors(errors, validationErrors); + const { fields: writeFilesFields, append: appendWriteFile, remove: removeWriteFile } = useFieldArray({ + control, + name: 'cloud_init_common.write_files', + }); + + const { fields: runcmdFields, append: appendRuncmd, remove: removeRuncmd } = useFieldArray({ + control, + name: 'cloud_init_common.runcmd' as any, + }); + + const handleAppendRuncmd = () => { + appendRuncmd({ command: '' }); + }; + + const handleRemoveRuncmd = (index: number) => { + removeRuncmd(index); + }; + + return ( +
+

+ These configurations are applied to ALL nodes during provisioning. +

+ +
+ +
+ + {writeFilesFields.map((field, index) => { + const pathError = getError(`cloud_init_common.write_files.${index}.path`); + const permissionsError = getError(`cloud_init_common.write_files.${index}.permissions`); + const contentError = getError(`cloud_init_common.write_files.${index}.content`); + return ( +
+
+
+ + + {pathError && {pathError.message}} +
+
+ + + {permissionsError && {permissionsError.message}} +
+
+
+
+ +