diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 310a1d248f..6b28f39d47 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -33,7 +33,8 @@ jobs: python -m pip install --upgrade pip pip install ansible pylint kubernetes prettytable \ requests passlib fastapi uvicorn sqlalchemy pytest \ - httpx argon2-cffi pyyaml dependency-injector + httpx argon2-cffi pyyaml dependency-injector \ + starlette pydantic - name: Get changed Python files (excluding deleted) id: changed-files @@ -73,7 +74,7 @@ jobs: # for proper import resolution # This allows pylint to resolve both relative imports # in build_stream and regular imports elsewhere - PYTHONPATH=.:./src/build_stream pylint $FILES \ + PYTHONPATH=.:./src/build_stream:./src/utils/gui pylint $FILES \ --fail-under=${PYLINT_THRESHOLD} else echo "No files to lint after filtering." diff --git a/src/utils/gui/.gitignore b/src/utils/gui/.gitignore new file mode 100644 index 0000000000..bff5af3a8d --- /dev/null +++ b/src/utils/gui/.gitignore @@ -0,0 +1,2 @@ +# GUI module test/run artifacts +.pytest_cache/ \ No newline at end of file diff --git a/src/utils/gui/README.md b/src/utils/gui/README.md new file mode 100644 index 0000000000..5b8d9326ec --- /dev/null +++ b/src/utils/gui/README.md @@ -0,0 +1,403 @@ +# 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: + +- **Build Configuration Module**: Visual editor for Omnia catalog JSON files with real-time validation along with local repository configuration for RHEL and Ubuntu +- **Deployment Configuration Module**: 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) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Build │ │ Deployment │ │ Adapter │ │ +│ │ Config │ │ Config │ │ 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 src/utils/gui +pip install -r backend/requirements.txt +``` + +Dependencies are in `src/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 (`src/utils/gui/out`), and base input files are loaded from `src/examples/` and the repository `src/input/` directory. + +### Running the Backend + +```bash +cd src/utils/gui +python -m 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 +``` + +## Testing + +### Backend Tests + +```bash +cd src/utils/gui +python -m pytest tests/ -v +``` + +### Frontend Tests + +```bash +cd src/utils/gui/frontend +npm test +``` + +## 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 `src/build_stream/core/catalog/resources/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 +cd src/utils/gui +gunicorn 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 (`src/utils/gui/out`), and catalog examples are loaded from `src/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**: `../../src/build_stream/core/catalog/ADAPTER_POLICY_GUIDE.md` + +## License + +Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. diff --git a/src/utils/gui/backend/.gitignore b/src/utils/gui/backend/.gitignore new file mode 100644 index 0000000000..2da54e473c --- /dev/null +++ b/src/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/src/utils/gui/backend/__init__.py b/src/utils/gui/backend/__init__.py new file mode 100644 index 0000000000..6508ff83d0 --- /dev/null +++ b/src/utils/gui/backend/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +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/src/utils/gui/backend/api/v1/__init__.py b/src/utils/gui/backend/api/v1/__init__.py new file mode 100644 index 0000000000..7f3a2f2647 --- /dev/null +++ b/src/utils/gui/backend/api/v1/__init__.py @@ -0,0 +1,34 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API v1 module.""" + +from .dependencies import ( + get_settings_dependency, + 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, +) + +__all__ = [ + "get_settings_dependency", + "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", +] diff --git a/src/utils/gui/backend/api/v1/dependencies.py b/src/utils/gui/backend/api/v1/dependencies.py new file mode 100644 index 0000000000..7c5a47d611 --- /dev/null +++ b/src/utils/gui/backend/api/v1/dependencies.py @@ -0,0 +1,125 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Dependency injection setup for Config Editor Module + +Provides FastAPI Depends functions for service injection following best practices. +""" + +from fastapi import Depends, Request + +# pylint: disable=relative-beyond-top-level +from ...config.settings import Settings, get_settings +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 +# pylint: enable=relative-beyond-top-level + + +# Settings dependency +def get_settings_dependency() -> Settings: + """FastAPI dependency for settings.""" + return get_settings() + + +# Service dependencies +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 diff --git a/src/utils/gui/backend/api/v1/routes/__init__.py b/src/utils/gui/backend/api/v1/routes/__init__.py new file mode 100644 index 0000000000..d47e5777dd --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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/src/utils/gui/backend/api/v1/routes/adapter_policy_routes.py b/src/utils/gui/backend/api/v1/routes/adapter_policy_routes.py new file mode 100644 index 0000000000..2a510086e1 --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/adapter_policy_routes.py @@ -0,0 +1,98 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Adapter Policy routes for Config Editor Module + +Provides API endpoints for adapter policy management. +""" + +import logging +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException + +# pylint: disable=relative-beyond-top-level +from ....core.exceptions import AdapterPolicyNotFoundError +from ....services.adapter_policy_service import AdapterPolicyService +# pylint: enable=relative-beyond-top-level +from ..dependencies import get_adapter_policy_service + +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", + ) from e + except (OSError, ValueError) as e: + logger.exception("Failed to get adapter policy") + raise HTTPException( + 500, "Failed to load adapter policy", + ) from e + + +@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) as e: + logger.exception("Failed to save adapter policy") + raise HTTPException( + 500, "Failed to save adapter policy", + ) from e + + +@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) as e: + logger.exception("Failed to delete adapter policy") + raise HTTPException( + 500, "Failed to delete adapter policy", + ) from e diff --git a/src/utils/gui/backend/api/v1/routes/catalog_editor_routes.py b/src/utils/gui/backend/api/v1/routes/catalog_editor_routes.py new file mode 100644 index 0000000000..7d8011ca00 --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/catalog_editor_routes.py @@ -0,0 +1,239 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""FastAPI routes for catalog editor - OS package templates and role mappings.""" + +import json +import logging +from typing import Dict, List + +from fastapi import APIRouter, Depends, Query + +# pylint: disable=relative-beyond-top-level +from ....api.v1.schemas.catalog_editor_schemas import ( + BundleListResponse, + BundleInfo, +) +from ....services.os_package_service import OSPackageService +from ....services.software_config_service import SoftwareConfigService +# pylint: enable=relative-beyond-top-level +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) + 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( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + 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( # pylint: disable=too-many-arguments,too-many-positional-arguments + 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( # pylint: disable=too-many-arguments,too-many-positional-arguments + 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/src/utils/gui/backend/api/v1/routes/catalog_routes.py b/src/utils/gui/backend/api/v1/routes/catalog_routes.py new file mode 100644 index 0000000000..96429d4e15 --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/catalog_routes.py @@ -0,0 +1,431 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Catalog Editor routes for Config Editor Module + +Provides API endpoints for direct catalog CRUD operations. +""" + +import logging +from typing import Any, Dict, List +from urllib.parse import unquote + +from fastapi import APIRouter, Depends, HTTPException, Request + +from ..dependencies import get_catalog_editor_service +# pylint: disable=relative-beyond-top-level +from ....services.catalog_editor_service import CatalogEditorService +from ....models.catalog_schemas import ( + CatalogRoot, + FunctionalPackage, + InfrastructurePackage, + FunctionalLayer, + DriverPackage, +) +# pylint: enable=relative-beyond-top-level + +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 as exc: + raise HTTPException( + status_code=404, + detail=f"Catalog preset not found: {filename}", + ) from exc + + +@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, # pylint: disable=unused-argument + 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 = [ + mid for mid in catalog.Catalog.Miscellaneous + if mid != 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/src/utils/gui/backend/api/v1/routes/local_repo_routes.py b/src/utils/gui/backend/api/v1/routes/local_repo_routes.py new file mode 100644 index 0000000000..2474c259c9 --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/local_repo_routes.py @@ -0,0 +1,140 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Local repository routes for Config Editor Module + +Provides API endpoints for local repository configuration generation. +""" + +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import ( + APIRouter, BackgroundTasks, Depends, HTTPException, Request, +) + +# pylint: disable=relative-beyond-top-level +from ....api.v1.dependencies import get_local_repo_generator_service +from ....services.job_store import ( + JobStore, TooManyConcurrentJobsError, +) +from ....services.local_repo_generator_service import ( + LocalRepoGeneratorService, +) +# pylint: enable=relative-beyond-top-level + +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: # pylint: disable=broad-except + 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 section (Ubuntu is disabled for later release) + 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 as e: + raise HTTPException( + 429, + "Too many generation jobs in progress. " + "Please wait.", + ) from e + + 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/src/utils/gui/backend/api/v1/routes/wizard_routes.py b/src/utils/gui/backend/api/v1/routes/wizard_routes.py new file mode 100644 index 0000000000..850e309b18 --- /dev/null +++ b/src/utils/gui/backend/api/v1/routes/wizard_routes.py @@ -0,0 +1,233 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Wizard routes for Config Editor Module + +Provides API endpoints for configuration generation and validation. +""" + +import logging +import shutil +import tempfile +import uuid +import zipfile +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import ( + APIRouter, BackgroundTasks, Depends, HTTPException, Request, +) +from fastapi.responses import FileResponse +from starlette.background import BackgroundTask + +# pylint: disable=relative-beyond-top-level +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 +# pylint: enable=relative-beyond-top-level +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: # pylint: disable=broad-except + 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 as e: + raise HTTPException( + 429, + "Too many generation jobs in progress. " + "Please wait.", + ) from e + + # 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 + """ + 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 OSError 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 OSError 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: {e}" + ) from e diff --git a/src/utils/gui/backend/api/v1/schemas/__init__.py b/src/utils/gui/backend/api/v1/schemas/__init__.py new file mode 100644 index 0000000000..bd96f0ef89 --- /dev/null +++ b/src/utils/gui/backend/api/v1/schemas/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Schemas module for catalog editor API.""" + +from .catalog_editor_schemas import ( + BundleInfo, + BundleListResponse, +) +from .wizard_schemas import ( + DownloadFilesRequest +) + +__all__ = [ + "BundleInfo", + "BundleListResponse", + "DownloadFilesRequest", +] diff --git a/src/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py b/src/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py new file mode 100644 index 0000000000..b7a7ed7ec8 --- /dev/null +++ b/src/utils/gui/backend/api/v1/schemas/catalog_editor_schemas.py @@ -0,0 +1,30 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Schemas for catalog editor API.""" + +from pydantic import BaseModel +from typing import List + + +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] diff --git a/src/utils/gui/backend/api/v1/schemas/wizard_schemas.py b/src/utils/gui/backend/api/v1/schemas/wizard_schemas.py new file mode 100644 index 0000000000..f1b61b5338 --- /dev/null +++ b/src/utils/gui/backend/api/v1/schemas/wizard_schemas.py @@ -0,0 +1,21 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Schemas for wizard configuration API.""" + +from pydantic import BaseModel + + +class DownloadFilesRequest(BaseModel): + """Request model for download-files endpoint.""" + input_dir: str diff --git a/src/utils/gui/backend/app.py b/src/utils/gui/backend/app.py new file mode 100644 index 0000000000..46db8d3015 --- /dev/null +++ b/src/utils/gui/backend/app.py @@ -0,0 +1,179 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +FastAPI application for Config Editor Module (Refactored) + +This is a production-ready FastAPI service with proper structure, +middleware, dependency injection, and configuration management. +""" + +import logging + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from .api.v1.routes import ( + adapter_policy_routes, + catalog_editor_router, + catalog_routes, + local_repo_routes, + wizard_routes, +) +from .config.logging import setup_logging +from .config.settings import get_settings +from .core.exceptions import ConfigEditorException +from .core.middleware import configure_middleware +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", + # Never expose exception details + "details": None, + } + ) + + +# 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( + "backend.app:app", + host=settings.host, + port=settings.port, + reload=settings.reload, + log_level=settings.log_level + ) diff --git a/src/utils/gui/backend/config/__init__.py b/src/utils/gui/backend/config/__init__.py new file mode 100644 index 0000000000..7484e28b8a --- /dev/null +++ b/src/utils/gui/backend/config/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Configuration module.""" + +from .settings import Settings, get_settings +from .logging import setup_logging + +__all__ = ["Settings", "get_settings", "setup_logging"] diff --git a/src/utils/gui/backend/config/defaults.py b/src/utils/gui/backend/config/defaults.py new file mode 100644 index 0000000000..1cc2274e26 --- /dev/null +++ b/src/utils/gui/backend/config/defaults.py @@ -0,0 +1,274 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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/src/input/*.yml` with all feature flags set to disabled. +""" + +from typing import Any, Dict + + +def get_build_stream_config_defaults() -> Dict[str, Any]: + """Return default build stream configuration.""" + 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 default discovery configuration.""" + return { + 'enable_bmc_discovery': False, + 'ome_ip': '', + 'admin_inventory_path': + '/opt/omnia/input/project_default/admin_inventory.csv', + } + + +def get_telemetry_config_defaults() -> Dict[str, Any]: + """Return default telemetry configuration.""" + 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 default telemetry storage configuration.""" + resources_256_500 = { + 'requests': {'memory': '256Mi', 'cpu': '100m'}, + 'limits': {'memory': '512Mi', 'cpu': '500m'}, + } + resources_128_256 = { + 'requests': {'memory': '128Mi', 'cpu': '50m'}, + 'limits': {'memory': '256Mi', 'cpu': '250m'}, + } + return { + 'victoria_cluster_storage': { + 'vmstorage': { + 'replicas': 3, + 'resources': { + 'requests': {'memory': '1Gi', 'cpu': '250m'}, + 'limits': {'memory': '2Gi', 'cpu': '1000m'}, + }, + }, + 'vminsert': { + 'replicas': 2, + 'resources': resources_256_500, + }, + 'vmselect': { + 'replicas': 2, + 'resources': resources_256_500, + }, + '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': resources_256_500, + }, + 'vlselect': { + 'replicas': 2, + 'resources': resources_256_500, + }, + 'vlagent': { + 'replicas': 2, + 'pvc_size': '5Gi', + 'resources': { + 'requests': {'memory': '64Mi', 'cpu': '25m'}, + 'limits': {'memory': '256Mi', 'cpu': '100m'}, + }, + }, + }, + 'vector_storage': { + 'ldms': { + 'replicas': 2, + 'resources': resources_128_256, + }, + 'ome': { + 'replicas': 2, + 'resources': resources_256_500, + }, + 'vlagent_vector': { + 'replicas': 2, + 'pvc_size': '5Gi', + 'resources': resources_128_256, + }, + 'vmagent_vector': { + 'replicas': 2, + 'pvc_size': '5Gi', + 'resources': resources_128_256, + }, + }, + '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/src/utils/gui/backend/config/logging.py b/src/utils/gui/backend/config/logging.py new file mode 100644 index 0000000000..b986edcdc1 --- /dev/null +++ b/src/utils/gui/backend/config/logging.py @@ -0,0 +1,70 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +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/src/utils/gui/backend/config/settings.py b/src/utils/gui/backend/config/settings.py new file mode 100644 index 0000000000..0c7a27e7ce --- /dev/null +++ b/src/utils/gui/backend/config/settings.py @@ -0,0 +1,85 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Configuration settings for Config Editor Module + +Provides environment-based configuration management using standard environment variables. +""" + +import os +from typing import Optional +from pathlib import Path + + +class Settings: # pylint: disable=too-many-instance-attributes,too-few-public-methods + """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") # nosec B104 + 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 + # Repository root + self.base_dir = ( + Path(__file__).parent.parent.parent.parent.parent.parent + ) + self.build_stream_dir = self.base_dir / "src" / "build_stream" + self.gui_dir = self.base_dir / "src" / "utils" / "gui" + # Base input for bundle files (repo root/src/input) + self.base_input_dir = self.base_dir / "src" / "input" + self.examples_dir = self.base_dir / "src" / "examples" + + # Output Configuration + self.output_dir = self.gui_dir / "out" + + # Environment + self.environment = os.getenv("ENVIRONMENT", "development") + self.debug = os.getenv("DEBUG", "true").lower() == "true" + + +# Global settings instance +_settings: Optional[Settings] = None # pylint: disable=invalid-name + + +def get_settings() -> Settings: + """Get or create the global settings instance.""" + global _settings # pylint: disable=global-statement + if _settings is None: + _settings = Settings() + return _settings diff --git a/src/utils/gui/backend/core/__init__.py b/src/utils/gui/backend/core/__init__.py new file mode 100644 index 0000000000..d63898664e --- /dev/null +++ b/src/utils/gui/backend/core/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Core module.""" + +from .exceptions import * +from .middleware import configure_middleware, CORSMiddlewareConfig + +__all__ = [ + "ConfigEditorException", + "AdapterPolicyNotFoundError", + "GenerationError", + "configure_middleware", + "CORSMiddlewareConfig", +] diff --git a/src/utils/gui/backend/core/constants.py b/src/utils/gui/backend/core/constants.py new file mode 100644 index 0000000000..b7543626d5 --- /dev/null +++ b/src/utils/gui/backend/core/constants.py @@ -0,0 +1,24 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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/src/utils/gui/backend/core/exceptions.py b/src/utils/gui/backend/core/exceptions.py new file mode 100644 index 0000000000..b6554a916b --- /dev/null +++ b/src/utils/gui/backend/core/exceptions.py @@ -0,0 +1,66 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Custom exception classes for Config Editor Module + +Provides domain-specific exceptions for better error handling and user feedback. +""" + +from typing import Any, Dict, Optional + + +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 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 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 + ) diff --git a/src/utils/gui/backend/core/middleware.py b/src/utils/gui/backend/core/middleware.py new file mode 100644 index 0000000000..0c6a5d79f5 --- /dev/null +++ b/src/utils/gui/backend/core/middleware.py @@ -0,0 +1,105 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Custom middleware for Config Editor Module + +Provides CORS and logging middleware. +""" + +import logging + +from fastapi import FastAPI, 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: FastAPI): + """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): # pylint: disable=too-few-public-methods + """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: FastAPI): + """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/src/utils/gui/backend/models/__init__.py b/src/utils/gui/backend/models/__init__.py new file mode 100644 index 0000000000..425c4634fe --- /dev/null +++ b/src/utils/gui/backend/models/__init__.py @@ -0,0 +1,32 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models module.""" + +from .catalog_schemas import * + +__all__ = [ + "PackageType", + "FunctionalLayer", + "BaseOS", + "Infrastructure", + "CatalogInner", + "CatalogRoot", + "DriverConfig", + "Driver", + "DriverPackage", + "FunctionalPackage", + "InfrastructurePackage", + "SupportedOSInfo", + "PackageSource", +] diff --git a/src/utils/gui/backend/models/catalog_schemas.py b/src/utils/gui/backend/models/catalog_schemas.py new file mode 100644 index 0000000000..2889cff75b --- /dev/null +++ b/src/utils/gui/backend/models/catalog_schemas.py @@ -0,0 +1,208 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Pydantic schemas for catalog data models.""" + +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +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): + """OS name and version pair.""" + Name: str + Version: str + + +class PackageSource(BaseModel): + """Source location for a package.""" + 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 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): + """Base OS definition with associated packages.""" + Name: str + Version: str + osPackages: List[str] + + +class Infrastructure(BaseModel): + """Infrastructure definition with associated packages.""" + 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/src/utils/gui/backend/requirements.txt b/src/utils/gui/backend/requirements.txt new file mode 100644 index 0000000000..b1f42eb1c0 --- /dev/null +++ b/src/utils/gui/backend/requirements.txt @@ -0,0 +1,13 @@ +# 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 + +# Testing +pytest>=7.4.0 diff --git a/src/utils/gui/backend/services/__init__.py b/src/utils/gui/backend/services/__init__.py new file mode 100644 index 0000000000..5ddf5cadac --- /dev/null +++ b/src/utils/gui/backend/services/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Services module.""" + +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__ = [ + "AdapterPolicyService", + "CatalogValidationService", + "OSPackageService", + "SoftwareConfigService", + "WizardGeneratorService", +] diff --git a/src/utils/gui/backend/services/adapter_policy_service.py b/src/utils/gui/backend/services/adapter_policy_service.py new file mode 100644 index 0000000000..89f0ab3067 --- /dev/null +++ b/src/utils/gui/backend/services/adapter_policy_service.py @@ -0,0 +1,131 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Adapter Policy Service for Config Editor Module + +Provides business logic for adapter policy management. +""" + +import logging +from typing import Any, Dict + +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(" + f"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)}, + ) from 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 OSError: + 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/src/utils/gui/backend/services/catalog_editor_service.py b/src/utils/gui/backend/services/catalog_editor_service.py new file mode 100644 index 0000000000..aa1611a5c0 --- /dev/null +++ b/src/utils/gui/backend/services/catalog_editor_service.py @@ -0,0 +1,254 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Service for managing catalog editor operations.""" + +import logging +import re +from typing import Optional + +from ..models.catalog_schemas import ( + CatalogRoot, + CatalogInner, + BaseOS, + Infrastructure, +) +from ..utils.file_io import read_json +from ..config.settings import get_settings + + +logger = logging.getLogger(__name__) + + +class CatalogEditorService: + """Service for catalog CRUD, validation, and preset loading.""" + # 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 + self.catalog_path = None + # 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 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 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/src/utils/gui/backend/services/catalog_validation_service.py b/src/utils/gui/backend/services/catalog_validation_service.py new file mode 100644 index 0000000000..e984ea4af1 --- /dev/null +++ b/src/utils/gui/backend/services/catalog_validation_service.py @@ -0,0 +1,210 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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 Any, Dict, List, Set + +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]: + """Convert to dictionary representation.""" + return { + 'level': self.level, + 'code': self.code, + 'message': self.message, + 'path': self.path + } + + def __repr__(self) -> str: + return ( + f"CatalogValidationError(" + f"level={self.level!r}, " + f"code={self.code!r}, " + f"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 ' + f'non-existent package: {pkg_id}' + ), + path=( + f'Catalog.{section_key}' + f'.{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}"' + f' uses architecture {arch}' + ' which is not found in' + ' any package' + ), + path=( + f'Catalog.{section_key}' + f'.{item_name}.Architecture' + ), + )) + + return errors diff --git a/src/utils/gui/backend/services/config_file_generators.py b/src/utils/gui/backend/services/config_file_generators.py new file mode 100644 index 0000000000..f0a12102c0 --- /dev/null +++ b/src/utils/gui/backend/services/config_file_generators.py @@ -0,0 +1,1062 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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 +from pathlib import Path +from typing import Any, Callable, Dict + +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): + return any( + has_meaningful_data(v) for v in data.values() + ) + if isinstance(data, list): + return any( + has_meaningful_data(item) for item in data + ) + if isinstance(data, bool): + return data + if isinstance(data, (int, float)): + return True + if 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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 + _special_chars = { + '/', '-', ':', '.', ' ', '#', '{', '}', + '[', ']', ',', '&', '*', '?', '|', '>', + '!', '%', '@', '`', + } + if any(ch in value for ch in _special_chars): + 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) + _size_units = ( + 'Gi', 'Mi', 'Ki', 'Ti', 'G', 'M', 'K', 'T', + 'GB', 'MB', 'KB', 'TB', 'm', + ) + if any(value.endswith(u) for u in _size_units): + 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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' (currently only 'rhel' is used) + + 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, # pylint: disable=unused-argument +) -> 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" disabled for later release + 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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): # pylint: disable=unused-argument + """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" disabled for later release + 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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, # pylint: disable=unused-argument +) -> 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 of group objects + # to {group_name: {write_files, runcmd}} mapping + 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 + has_common = has_meaningful_data(cloud_init.get("common")) + has_groups = has_meaningful_data(cloud_init.get("groups")) + if not has_common and not has_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, # pylint: disable=unused-argument +) -> 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, + ) + + +# Admin Inventory CSV columns for Magellan discovery +_ADMIN_INVENTORY_CSV_COLUMNS = ( + "SERVICE_TAG", "GROUP_NAME", "FUNCTIONAL_GROUP_NAME", + "ROW", "RACK", "SLOT", "RANGE", +) + + +def generate_admin_inventory_csv( + wizard_data: Dict[str, Any], + input_dir: Path, + ensure_directory_fn: Callable, +) -> None: + """Generate admin_inventory.csv from wizard data for Magellan discovery. + + Args: + wizard_data: Dictionary containing wizard form data + input_dir: Directory where config files should be written + ensure_directory_fn: Callback for ensuring directory exists + """ + rows = wizard_data.get("admin_inventory_data") + if not rows: + logger.info("Skipped admin_inventory.csv (no admin_inventory_data)") + return + + csv_path = input_dir / "admin_inventory.csv" + ensure_directory_fn(csv_path.parent) + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter( + f, + fieldnames=_ADMIN_INVENTORY_CSV_COLUMNS, + extrasaction="ignore", + ) + writer.writeheader() + for row in rows: + writer.writerow({ + col: (row.get(col) or "") + for col in _ADMIN_INVENTORY_CSV_COLUMNS + }) + + logger.info("Generated admin_inventory.csv with %d rows", len(rows)) diff --git a/src/utils/gui/backend/services/job_store.py b/src/utils/gui/backend/services/job_store.py new file mode 100644 index 0000000000..0965661035 --- /dev/null +++ b/src/utils/gui/backend/services/job_store.py @@ -0,0 +1,236 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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) diff --git a/src/utils/gui/backend/services/local_repo_generator_service.py b/src/utils/gui/backend/services/local_repo_generator_service.py new file mode 100644 index 0000000000..82ad52e24c --- /dev/null +++ b/src/utils/gui/backend/services/local_repo_generator_service.py @@ -0,0 +1,112 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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: # pylint: disable=too-few-public-methods + """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)}" + ) from 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/src/utils/gui/backend/services/os_package_service.py b/src/utils/gui/backend/services/os_package_service.py new file mode 100644 index 0000000000..011ebbd4fe --- /dev/null +++ b/src/utils/gui/backend/services/os_package_service.py @@ -0,0 +1,537 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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 is disabled for later release) + 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} + ] + """ + os_family = os_family.lower() + 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"} + ] + } + """ + os_family = os_family.lower() + 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": [...] + } + """ + os_family = os_family.lower() + # 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" + } + ] + """ + os_family = os_family.lower() + 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/src/utils/gui/backend/services/software_config_service.py b/src/utils/gui/backend/services/software_config_service.py new file mode 100644 index 0000000000..b0293b24fd --- /dev/null +++ b/src/utils/gui/backend/services/software_config_service.py @@ -0,0 +1,235 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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 diff --git a/src/utils/gui/backend/services/wizard_generator_service.py b/src/utils/gui/backend/services/wizard_generator_service.py new file mode 100644 index 0000000000..fdd5735d41 --- /dev/null +++ b/src/utils/gui/backend/services/wizard_generator_service.py @@ -0,0 +1,269 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""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, + generate_admin_inventory_csv +) + +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", + "admin_inventory.csv" +] + + +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 + config_keys = ( + "pxe_mapping_data", "dns_enabled", + "default_lease_time", "language", + "kernel_version_override", + "additional_cloud_init_config_file", + "mounts", "cloud_init_common", + "slurm_cluster", "service_k8s_cluster", + "service_k8s_cluster_ha", + "enable_build_stream", + "enable_bmc_discovery", "gitlab_host", + "telemetry_sources", "telemetry_sinks", + "user_registry_name", "user_registry", + "user_repo_url_x86_64", + "user_repo_url_aarch64", + "user_registry_username", "Networks", + ) + has_any_config_data = any( + wizard_data.get(key) for key in config_keys + ) + + 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 " + f"{input_dir}: {e}" + ) from 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) + + gen_args = (wizard_data, input_dir, write_yaml) + dir_args = (wizard_data, input_dir, ensure_directory) + generator_specs = [ + ("pxe_mapping_file.csv", generate_pxe_mapping_file, dir_args), + ("provision_config.yml", generate_provision_config, gen_args), + ("storage_config.yml", generate_storage_config, gen_args), + ("additional_cloud_init.yml", generate_additional_cloud_init, gen_args), + ("security_config.yml", generate_security_config, gen_args), + ("omnia_config.yml", generate_omnia_config, gen_args), + ("network_spec.yml", generate_network_spec, gen_args), + ("gitlab_config.yml", generate_gitlab_config, gen_args), + ("build_stream_config.yml", generate_build_stream_config, gen_args), + ("discovery_config.yml", generate_discovery_config, gen_args), + ("high_availability_config.yml", generate_high_availability_config, gen_args), + ("telemetry_config.yml", generate_telemetry_config, gen_args), + ("telemetry_storage_config.yml", generate_telemetry_storage_config, gen_args), + ("user_registry_credential.yml", generate_user_registry_credential, gen_args), + ("admin_inventory.csv", generate_admin_inventory_csv, dir_args), + ] + + 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/src/utils/gui/backend/utils/__init__.py b/src/utils/gui/backend/utils/__init__.py new file mode 100644 index 0000000000..d4f505a51d --- /dev/null +++ b/src/utils/gui/backend/utils/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utils module.""" + +from .file_io import * + +__all__ = [ + "read_json", + "write_json", + "write_yaml", + "ensure_directory", +] diff --git a/src/utils/gui/backend/utils/file_io.py b/src/utils/gui/backend/utils/file_io.py new file mode 100644 index 0000000000..2afe4a79d3 --- /dev/null +++ b/src/utils/gui/backend/utils/file_io.py @@ -0,0 +1,187 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +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 logging +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Union + +import yaml + +from ..core.exceptions import ConfigEditorException + +logger = logging.getLogger(__name__) + + +class IndentedListDumper(yaml.Dumper): # pylint: disable=too-many-ancestors + """Custom YAML dumper that indents list items.""" + def increase_indent(self, flow=False, indentless=False): + return super().increase_indent(flow, False) + + +class QuotedStringDumper(yaml.Dumper): # pylint: disable=too-many-ancestors + """Custom YAML dumper that quotes all string values.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.in_mapping_key = False + + def represent_mapping(self, tag, mapping, flow_style=None): + """Track when we're representing keys vs values.""" + 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', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + raise + except OSError as e: + raise ConfigEditorException( + f"Failed to read JSON file {path}: {e}" + ) from 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', encoding='utf-8') as f: + json.dump(data, f, indent=indent) + except OSError as e: + raise ConfigEditorException( + f"Failed to write JSON file {path}: {e}" + ) from 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("Created backup at %s", backup_path) + + # Write new data + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=indent) + except OSError as e: + raise ConfigEditorException( + f"Failed to write JSON file {path}: {e}" + ) from 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', encoding='utf-8') as f: + yaml.dump( + data, + f, + Dumper=IndentedListDumper, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + indent=2 + ) + except OSError as e: + raise ConfigEditorException( + f"Failed to write YAML file {path}: {e}" + ) from e + + +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/src/utils/gui/frontend/.gitignore b/src/utils/gui/frontend/.gitignore new file mode 100644 index 0000000000..1a1516ba41 --- /dev/null +++ b/src/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/src/utils/gui/frontend/index.html b/src/utils/gui/frontend/index.html new file mode 100644 index 0000000000..b127d2155d --- /dev/null +++ b/src/utils/gui/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + OMNIA Catalog Configuration + + + +
+ + + diff --git a/src/utils/gui/frontend/package-lock.json b/src/utils/gui/frontend/package-lock.json new file mode 100644 index 0000000000..943b69b92a --- /dev/null +++ b/src/utils/gui/frontend/package-lock.json @@ -0,0 +1,3979 @@ +{ + "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", + "esbuild": "npm:esbuild-wasm@^0.28.1", + "esbuild-wasm": "^0.28.1", + "jsdom": "^30.0.1", + "typescript": "^4.9.5", + "vite": "^8.0.16", + "vitest": "^3.2.1" + } + }, + "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/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "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/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "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/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.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/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "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/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.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/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/esbuild": { + "name": "esbuild-wasm", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.1.tgz", + "integrity": "sha512-p/GD4E8oYRjg3kjdKrnMb0s4PzXgJF42e0MF4H0+ACyK/kIlFRp3e0fzOleIG+wBBm6MM3XQrbpe7soEA+vJIA==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.1.tgz", + "integrity": "sha512-p/GD4E8oYRjg3kjdKrnMb0s4PzXgJF42e0MF4H0+ACyK/kIlFRp3e0fzOleIG+wBBm6MM3XQrbpe7soEA+vJIA==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "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/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "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/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.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": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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/src/utils/gui/frontend/package.json b/src/utils/gui/frontend/package.json new file mode 100644 index 0000000000..34b60cf7ca --- /dev/null +++ b/src/utils/gui/frontend/package.json @@ -0,0 +1,41 @@ +{ + "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", + "esbuild": "npm:esbuild-wasm@^0.28.1", + "esbuild-wasm": "^0.28.1", + "jsdom": "^30.0.1", + "typescript": "^4.9.5", + "vite": "^8.0.16", + "vitest": "^3.2.1" + }, + "scripts": { + "start": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "overrides": { + "esbuild": "npm:esbuild-wasm@0.28.1" + } +} diff --git a/src/utils/gui/frontend/public/favicon.png b/src/utils/gui/frontend/public/favicon.png new file mode 100644 index 0000000000..f7fec2b967 Binary files /dev/null and b/src/utils/gui/frontend/public/favicon.png differ diff --git a/src/utils/gui/frontend/src/App.tsx b/src/utils/gui/frontend/src/App.tsx new file mode 100644 index 0000000000..8335b16fd7 --- /dev/null +++ b/src/utils/gui/frontend/src/App.tsx @@ -0,0 +1,65 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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 { MagellanDiscoveryFlow } from './features/configuration-wizard/MagellanDiscoveryFlow'; +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/src/utils/gui/frontend/src/assets/omnia-logo.png b/src/utils/gui/frontend/src/assets/omnia-logo.png new file mode 100644 index 0000000000..f2f2bf6692 Binary files /dev/null and b/src/utils/gui/frontend/src/assets/omnia-logo.png differ diff --git a/src/utils/gui/frontend/src/components/Button.tsx b/src/utils/gui/frontend/src/components/Button.tsx new file mode 100644 index 0000000000..7b5cbaf7c2 --- /dev/null +++ b/src/utils/gui/frontend/src/components/Button.tsx @@ -0,0 +1,55 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { ReactNode } from 'react'; + +interface ButtonProps { + children: ReactNode; + variant?: 'primary' | 'secondary' | 'tertiary' | 'outline' | 'link'; + size?: 'sm' | 'md' | 'lg'; + className?: string; + style?: React.CSSProperties; + onClick?: () => 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/src/utils/gui/frontend/src/components/ErrorBoundary.tsx b/src/utils/gui/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..441076a38b --- /dev/null +++ b/src/utils/gui/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,84 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/components/Layout.tsx b/src/utils/gui/frontend/src/components/Layout.tsx new file mode 100644 index 0000000000..a2ba9e9a15 --- /dev/null +++ b/src/utils/gui/frontend/src/components/Layout.tsx @@ -0,0 +1,259 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/components/LoadingSpinner.tsx b/src/utils/gui/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000000..3b8bd87b06 --- /dev/null +++ b/src/utils/gui/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,48 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/components/NotFound.tsx b/src/utils/gui/frontend/src/components/NotFound.tsx new file mode 100644 index 0000000000..060af413ef --- /dev/null +++ b/src/utils/gui/frontend/src/components/NotFound.tsx @@ -0,0 +1,28 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx b/src/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx new file mode 100644 index 0000000000..b4b76ee8e9 --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/AdapterPolicyEditor.tsx @@ -0,0 +1,407 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx b/src/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx new file mode 100644 index 0000000000..0f0610ad71 --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/components/AddTargetSection.tsx @@ -0,0 +1,45 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx b/src/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx new file mode 100644 index 0000000000..24b6b3bb72 --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/components/TargetEditSection.tsx @@ -0,0 +1,421 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx b/src/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx new file mode 100644 index 0000000000..d1fcc0cdf9 --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/components/TargetListSection.tsx @@ -0,0 +1,47 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts b/src/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts new file mode 100644 index 0000000000..fabef9ae4b --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/hooks/useAdapterPolicy.ts @@ -0,0 +1,57 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts b/src/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts new file mode 100644 index 0000000000..fecba7e4f3 --- /dev/null +++ b/src/utils/gui/frontend/src/features/adapter-policy/schemas/adapterPolicy.ts @@ -0,0 +1,130 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css b/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css new file mode 100644 index 0000000000..43ff529829 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.css @@ -0,0 +1,229 @@ +/* Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* 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/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx new file mode 100644 index 0000000000..7f4fd42a70 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/CatalogEditor.tsx @@ -0,0 +1,280 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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 driverPkgCount = Object.keys( + inner?.DriverPackages ?? {}, + ).length; + const miscPkgCount = inner?.Miscellaneous?.length ?? 0; + + 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 (${driverPkgCount})`], + ['miscellaneous', `Miscellaneous (${miscPkgCount})`], + ['validation', `Validation ${validationIcon}`], + ] as const, [layerCount, osPkgCount, infraPkgCount, driverPkgCount, miscPkgCount, 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/src/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts b/src/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts new file mode 100644 index 0000000000..308315efc0 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/catalogStore.ts @@ -0,0 +1,72 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx new file mode 100644 index 0000000000..81296acf7e --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/BundleSelector.tsx @@ -0,0 +1,181 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx new file mode 100644 index 0000000000..f6c1b92e94 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/CatalogOverview.tsx @@ -0,0 +1,105 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx new file mode 100644 index 0000000000..2c1ce5f26a --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/DriverPackageEditor.tsx @@ -0,0 +1,326 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx new file mode 100644 index 0000000000..239c522df0 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/EmptyLayerSelector.tsx @@ -0,0 +1,97 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx new file mode 100644 index 0000000000..b4608b2a55 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/FunctionalLayerEditor.tsx @@ -0,0 +1,838 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { useState, useEffect, useRef, useMemo } 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: '', +}; + +const OS_DISPLAY_NAME: Record = { + rhel: 'RHEL', + // ubuntu: 'Ubuntu', // Disabled for later release +}; + +const DEFAULT_VERSION: Record = { + rhel: '10.0', + // ubuntu: '22.04', // Disabled for later release +}; + +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 handleOsFamilyChange = (newFamily: string) => { + setOsFamily(newFamily); + setOsVersion(DEFAULT_VERSION[newFamily] ?? ''); + }; + + 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: OS_DISPLAY_NAME[osFamily] ?? 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: OS_DISPLAY_NAME[osFamily] ?? 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: OS_DISPLAY_NAME[osFamily] ?? osFamily, Version: osVersion }], + 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; + }; + const currentDefaultPackage = useMemo(() => ({ + ...defaultPackage, + SupportedOS: [{ + Name: OS_DISPLAY_NAME[osFamily] ?? osFamily, + Version: osVersion, + }], + }), [osFamily, osVersion]); + + 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}`} + defaultValues={currentDefaultPackage} + /> + )} + {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/src/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx new file mode 100644 index 0000000000..f7f3b43fe2 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/InfrastructureEditor.tsx @@ -0,0 +1,719 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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 DEFAULT_VERSION: Record = { + rhel: '10.0', + // ubuntu: '22.04', // Disabled for later release +}; + +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 handleOsFamilyChange = (newFamily: string) => { + setOsFamily(newFamily); + setOsVersion(DEFAULT_VERSION[newFamily] ?? ''); + }; + + 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

+
+
+ + +
+ +
+ + setOsVersion(e.target.value)} + className="form-input" + placeholder='e.g. 10.0' + /> +
+ +
+ + +
+
+ + + +
+ + +
+
+ )} + + {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/src/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx new file mode 100644 index 0000000000..39dcdc3af2 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/MiscellaneousEditor.tsx @@ -0,0 +1,279 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx new file mode 100644 index 0000000000..76745af452 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/OSPackageEditor.tsx @@ -0,0 +1,797 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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 OS_DISPLAY_NAME: Record = { + rhel: 'RHEL', + // ubuntu: 'Ubuntu', // Disabled for later release +}; + +const DEFAULT_VERSION: Record = { + rhel: '10.0', + // ubuntu: '22.04', // Disabled for later release +}; + +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 handleOsFamilyChange = (newFamily: string) => { + setOsFamily(newFamily); + setOsVersion(DEFAULT_VERSION[newFamily] ?? ''); + }; + + 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, version and display name from first package (or use defaults) + let derivedOsVersion = osVersion; + let baseOsDisplayName = OS_DISPLAY_NAME[osFamily] ?? osFamily; + let derivedOsFamily = osFamily; + + if (osPackageIds.length > 0) { + const firstPkg = catalogRoot.Catalog.OSPackages[osPackageIds[0]]; + if (firstPkg?.SupportedOS && firstPkg.SupportedOS.length > 0) { + baseOsDisplayName = firstPkg.SupportedOS[0].Name; + derivedOsVersion = firstPkg.SupportedOS[0].Version; + derivedOsFamily = firstPkg.SupportedOS[0].Name.toLowerCase(); + } + } + + // Sync React state with loaded catalog if it differs + if (derivedOsFamily !== osFamily) setOsFamily(derivedOsFamily); + if (derivedOsVersion !== osVersion) setOsVersion(derivedOsVersion); + + // Update BaseOS to match OSPackages + const updatedCatalog = { + ...catalogRoot, + Catalog: { + ...catalogRoot.Catalog, + BaseOS: [{ + Name: baseOsDisplayName, + 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: OS_DISPLAY_NAME[osFamily] ?? 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

+
+
+ + +
+ +
+ + setOsVersion(e.target.value)} + className="form-input" + placeholder='e.g. 10.0' + /> +
+ +
+ + +
+
+ + + +
+ + +
+
+ )} + + {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/src/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx new file mode 100644 index 0000000000..79d392f0b9 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/PackageForm.tsx @@ -0,0 +1,348 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx new file mode 100644 index 0000000000..fb643cd367 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/RoleSelector.tsx @@ -0,0 +1,121 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx b/src/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx new file mode 100644 index 0000000000..49fd97d08e --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/components/ValidationPanel.tsx @@ -0,0 +1,122 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts b/src/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts new file mode 100644 index 0000000000..91ea4c4204 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/constants/emptyCatalog.ts @@ -0,0 +1,31 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts new file mode 100644 index 0000000000..4eb185a7a6 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useBundleSelection.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { useQuery } from '@tanstack/react-query'; + +interface BundleInfo { + name: string; + type: string; + package_count: number; + sections: 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, + }); +}; + diff --git a/src/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts new file mode 100644 index 0000000000..1bf38ecdcc --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useCatalog.ts @@ -0,0 +1,230 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts new file mode 100644 index 0000000000..9dea7edc99 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/hooks/useRoleMappings.ts @@ -0,0 +1,48 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts b/src/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts new file mode 100644 index 0000000000..eb112bc786 --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/schemas/catalogSchema.ts @@ -0,0 +1,206 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { z } from 'zod'; +import { URL_PATTERN } from '../../configuration-wizard/schemas/common'; + +// ─── Enums ───────────────────────────────────────────────── + +const PackageType = z.enum([ + 'rpm', + 'rpm_repo', + 'tarball', + 'iso', + 'git', + 'image', + 'pip_module', + 'manifest', +]); + +// ─── Sub-schemas ─────────────────────────────────────────── + +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()); +}; + +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'], + }); + +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 ─────────────────────────────────────────── + +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'), +}); + +const DriverSchema = z.object({ + Name: z.string(), + DriverPackages: z.array(z.string()), +}); + +// ─── Structural schemas ──────────────────────────────────── + +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) +}); + +const BaseOSSchema = z.object({ + Name: z.string(), + Version: z.string(), + osPackages: z.array(z.string()), +}); + +const InfrastructureSchema = z.object({ + Name: z.string(), + InfrastructurePackages: z.array(z.string()), +}); + +// ─── CatalogInner: everything under "Catalog" ────────────── + +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": { ... } } ───────── + +const CatalogRootSchema = z.object({ + Catalog: CatalogInnerSchema, +}); + +// ─── Inferred types ──────────────────────────────────────── + +export type PackageTypeValue = z.infer; +export type SupportedOS = 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 DriverPackage = z.infer; +export type Driver = z.infer; +export type FunctionalLayer = z.infer; +export type BaseOS = z.infer; +export type CatalogRoot = z.infer; diff --git a/src/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts b/src/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts new file mode 100644 index 0000000000..a40d0c413d --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/utils/cleanCatalogForExport.ts @@ -0,0 +1,163 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts b/src/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts new file mode 100644 index 0000000000..9ce211a2cc --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog-editor/utils/extractErrorMessage.ts @@ -0,0 +1,77 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx b/src/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx new file mode 100644 index 0000000000..7ce8b45d4f --- /dev/null +++ b/src/utils/gui/frontend/src/features/catalog/CatalogViewer.tsx @@ -0,0 +1,502 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx new file mode 100644 index 0000000000..488381c5a0 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/BmcDiscoveryFlow.tsx @@ -0,0 +1,333 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx new file mode 100644 index 0000000000..3d531e7299 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/ConfigurationWizard.tsx @@ -0,0 +1,125 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/MagellanDiscoveryFlow.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/MagellanDiscoveryFlow.tsx new file mode 100644 index 0000000000..cc41640001 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/MagellanDiscoveryFlow.tsx @@ -0,0 +1,501 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { useState, useEffect, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +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'; +import { parseAdminInventoryFile, ADMIN_INVENTORY_COLUMNS } from './utils/adminInventoryCsvParser'; +import type { AdminInventoryRow } from './utils/adminInventoryCsvParser'; + +interface JobStatusResponse extends JobStatus { + progress?: number; + error?: string; +} + +const MAGELLAN_STEPS = [ + { id: 1, title: 'Admin Inventory', description: 'Configure admin inventory for Magellan discovery' }, + { id: 2, title: 'Network Configuration', description: 'Configure network settings for Magellan discovery' }, + { id: 3, title: 'Summary & Generate', description: 'Review and generate discovery configuration files' }, +]; + +export const MagellanDiscoveryFlow = () => { + const navigate = useNavigate(); + const updateWizardFields = useConfigStore((s) => s.updateWizardFields); + const updateWizardField = useConfigStore((s) => s.updateWizardField); + 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 [parseError, setParseError] = useState(null); + const [editingRow, setEditingRow] = useState(null); + const [editFormData, setEditFormData] = useState(null); + const fileInputRef = useRef(null); + + const adminInventoryPath = (wizardData.admin_inventory_path as string) || '/opt/omnia/input/project_default/admin_inventory.csv'; + const parsedData = Array.isArray(wizardData.admin_inventory_data) ? wizardData.admin_inventory_data as AdminInventoryRow[] : []; + + const generateAll = useGenerateAll(); + const { data: jobStatus } = useJobStatus(jobId ?? '') as { data: JobStatusResponse | undefined }; + const activeJobStatus = jobId ? jobStatus : undefined; + + // 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]); + + // Check if step 1 is valid + const isStep1Valid = useMemo(() => { + return adminInventoryPath.trim().length > 0 && parsedData.length > 0; + }, [adminInventoryPath, parsedData]); + + const handleNext = () => { + if (activeStep < MAGELLAN_STEPS.length) { + setActiveStep(activeStep + 1); + } + }; + + const handleBack = () => { + if (activeStep > 1) { + setActiveStep(activeStep - 1); + } + }; + + const handleCancel = () => { + navigate('/wizard'); + }; + + // CSV file upload + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const data = await parseAdminInventoryFile(file); + setParseError(null); + updateWizardField('admin_inventory_data', data); + } catch (error) { + setParseError(error instanceof Error ? error.message : 'Failed to parse CSV file'); + } + }; + + // Inline table editing + const handleAddRow = () => { + const newRow: AdminInventoryRow = { + SERVICE_TAG: '', + GROUP_NAME: '', + FUNCTIONAL_GROUP_NAME: '', + ROW: '', + RACK: '', + SLOT: '', + RANGE: '', + }; + updateWizardField('admin_inventory_data', [...parsedData, newRow]); + setEditingRow(parsedData.length); + setEditFormData(newRow); + }; + + const handleEditRow = (index: number) => { + setEditingRow(index); + setEditFormData({ ...parsedData[index] }); + }; + + const handleSaveRow = () => { + if (editingRow !== null && editFormData) { + const updatedData = [...parsedData]; + updatedData[editingRow] = editFormData; + updateWizardField('admin_inventory_data', updatedData); + setEditingRow(null); + setEditFormData(null); + } + }; + + const handleCancelEdit = () => { + setEditingRow(null); + setEditFormData(null); + }; + + const handleDeleteRow = (index: number) => { + updateWizardField('admin_inventory_data', parsedData.filter((_, i) => i !== index)); + }; + + const handleEditFieldChange = (field: keyof AdminInventoryRow, value: string) => { + if (editFormData) { + setEditFormData({ ...editFormData, [field]: value }); + } + }; + + const handleAdminInventoryPathChange = (e: React.ChangeEvent) => { + updateWizardField('admin_inventory_path', e.target.value); + }; + + // Generate handler + const handleGenerate = async () => { + setGenerationError(null); + if (parsedData.length === 0) { + setGenerationError('At least one admin inventory row is required before generating.'); + return; + } + setIsGenerating(true); + setGenerationProgress(0); + + try { + const magellanData = { + enable_bmc_discovery: false, + admin_inventory_path: adminInventoryPath, + admin_inventory_data: parsedData, + Networks: wizardData.Networks, + files_to_generate: ['discovery_config.yml', 'admin_inventory.csv', 'network_spec.yml'], + }; + + const result = await generateAll.mutateAsync(magellanData) 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); + + updateWizardFields({ + enable_bmc_discovery: false, + admin_inventory_path: adminInventoryPath, + }); + + setConfigMode(null); + showAlert('Magellan 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, adminInventoryPath, navigate, setConfigMode, setWizardActiveStep]); + + const currentStep = MAGELLAN_STEPS.find(s => s.id === activeStep); + + return ( +
+
+

Magellan Discovery Setup

+ {currentStep && ( +

{currentStep.description}

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

Admin Inventory

+ +
+ + + {parseError && ( +
{parseError}
+ )} + {parsedData.length === 0 && !parseError && ( +
+

+ No admin inventory data loaded. Upload a CSV file or create a new inventory. +

+ +
+ )} +
+ +
+ + +

+ Default: /opt/omnia/input/project_default/admin_inventory.csv +

+
+
+ + {parsedData.length > 0 && ( +
+
+

Admin Inventory Data ({parsedData.length} rows)

+ +
+
+ + + + {ADMIN_INVENTORY_COLUMNS.map((col) => ( + + ))} + + + + + {parsedData.map((row, index) => ( + + {editingRow === index ? ( + <> + {ADMIN_INVENTORY_COLUMNS.map((col) => ( + + ))} + + + ) : ( + <> + {ADMIN_INVENTORY_COLUMNS.map((col) => ( + + ))} + + + )} + + ))} + +
+ {col.replace(/_/g, ' ')} + Actions
+ handleEditFieldChange(col, e.target.value)} + className="pxe-mapping-input" + placeholder={col === 'SERVICE_TAG' ? 'Required' : 'Optional'} + /> + + + + + {row[col] || '-'} + + + +
+
+
+ )} + +
+ + +
+
+ )} + + {activeStep === 2 && ( +
+
+

Network Configuration

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

Summary

+
+
+

Admin Inventory Path

+

{adminInventoryPath}

+
+
+

Admin Inventory Rows

+

{parsedData.length} row(s)

+
+
+

Network Configuration

+

+ Network settings will be included in the generated files +

+
+
+

Files to Generate

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

Generating Configuration Files...

+
+
+
+
+

+ {generationProgress}% Complete +

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

Generation Complete!

+

+ Magellan discovery configuration files have been generated successfully. +

+
+ )} + +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx new file mode 100644 index 0000000000..4ced55e7c7 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/components/DeploymentOverview.tsx @@ -0,0 +1,147 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx new file mode 100644 index 0000000000..6a5b4a2970 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/components/DnsInput.tsx @@ -0,0 +1,64 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx new file mode 100644 index 0000000000..6a0e096174 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/components/Tabs.tsx @@ -0,0 +1,48 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/configStore.ts b/src/utils/gui/frontend/src/features/configuration-wizard/configStore.ts new file mode 100644 index 0000000000..a60c482c4b --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/configStore.ts @@ -0,0 +1,296 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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 and admin inventory data + if (key === 'pxe_mapping_data' || key === 'pxe_mapping_file_path' + || key === 'admin_inventory_data' || key === 'admin_inventory_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 // gitleaks:allow + })); + 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/src/utils/gui/frontend/src/features/configuration-wizard/constants.ts b/src/utils/gui/frontend/src/features/configuration-wizard/constants.ts new file mode 100644 index 0000000000..87213f83c5 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/constants.ts @@ -0,0 +1,34 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts b/src/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts new file mode 100644 index 0000000000..fbfce19bb2 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/hooks/useFormErrors.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/deploymentConfigs.test.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/deploymentConfigs.test.ts new file mode 100644 index 0000000000..6ac1549d9e --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/deploymentConfigs.test.ts @@ -0,0 +1,104 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { describe, it, expect } from 'vitest' +import { deploymentConfigsSchema } from '../deploymentConfigs' + +describe('deploymentConfigsSchema', () => { + const validAdminNetwork = { + admin_network: { + oim_nic_name: 'eth0', + subnet: '10.0.0.0', + netmask_bits: '24', + primary_oim_admin_ip: '10.0.0.1', + primary_oim_bmc_ip: '', + dynamic_range: '10.0.0.100-10.0.0.200', + }, + } + + it('accepts valid admin network', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [validAdminNetwork], + }) + expect(result.success).toBe(true) + }) + + it('rejects empty Networks array', () => { + const result = deploymentConfigsSchema.safeParse({ Networks: [] }) + expect(result.success).toBe(false) + }) + + it('rejects missing admin_network', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [{ ib_network: { subnet: '', netmask_bits: '' } }], + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid subnet IP', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [{ + admin_network: { ...validAdminNetwork.admin_network, subnet: '999.999.999.999' }, + }], + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid netmask_bits', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [{ + admin_network: { ...validAdminNetwork.admin_network, netmask_bits: '33' }, + }], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing oim_nic_name', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [{ + admin_network: { ...validAdminNetwork.admin_network, oim_nic_name: '' }, + }], + }) + expect(result.success).toBe(false) + }) + + it('accepts valid IB network alongside admin', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [ + validAdminNetwork, + { ib_network: { subnet: '192.168.1.0', netmask_bits: '24' } }, + ], + }) + expect(result.success).toBe(true) + }) + + it('rejects IB with subnet but no netmask', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [ + validAdminNetwork, + { ib_network: { subnet: '192.168.1.0', netmask_bits: '' } }, + ], + }) + expect(result.success).toBe(false) + }) + + it('rejects mismatched admin and IB netmask_bits', () => { + const result = deploymentConfigsSchema.safeParse({ + Networks: [ + validAdminNetwork, + { ib_network: { subnet: '192.168.1.0', netmask_bits: '16' } }, + ], + }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/magellanDiscovery.test.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/magellanDiscovery.test.ts new file mode 100644 index 0000000000..6da5b7a4ed --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/magellanDiscovery.test.ts @@ -0,0 +1,76 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { describe, it, expect } from 'vitest' +import { magellanDiscoverySchema } from '../magellanDiscovery' + +describe('magellanDiscoverySchema', () => { + const validData = { + admin_inventory_path: '/opt/omnia/input/project_default/admin_inventory.csv', + admin_inventory_data: [ + { + SERVICE_TAG: 'SVC001', + GROUP_NAME: 'group1', + FUNCTIONAL_GROUP_NAME: 'compute', + ROW: 'R1', + RACK: 'A1', + SLOT: '1', + RANGE: '', + }, + ], + } + + it('accepts valid data', () => { + const result = magellanDiscoverySchema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('rejects empty admin_inventory_path', () => { + const result = magellanDiscoverySchema.safeParse({ + ...validData, + admin_inventory_path: '', + }) + expect(result.success).toBe(false) + }) + + it('rejects empty admin_inventory_data', () => { + const result = magellanDiscoverySchema.safeParse({ + ...validData, + admin_inventory_data: [], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing SERVICE_TAG', () => { + const result = magellanDiscoverySchema.safeParse({ + ...validData, + admin_inventory_data: [ + { SERVICE_TAG: '', GROUP_NAME: '', FUNCTIONAL_GROUP_NAME: '' }, + ], + }) + expect(result.success).toBe(false) + }) + + it('defaults optional fields', () => { + const result = magellanDiscoverySchema.safeParse({ + admin_inventory_path: '/path', + admin_inventory_data: [{ SERVICE_TAG: 'SVC001' }], + }) + expect(result.success).toBe(true) + if (result.success) { + const row = result.data.admin_inventory_data[0] + expect(row.GROUP_NAME).toBe('') + expect(row.ROW).toBe('') + } + }) +}) diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/pxeFunctionalGroups.test.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/pxeFunctionalGroups.test.ts new file mode 100644 index 0000000000..9f62a9ad68 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/__tests__/pxeFunctionalGroups.test.ts @@ -0,0 +1,144 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { describe, it, expect } from 'vitest' +import { pxeFunctionalGroupsSchema } from '../pxeFunctionalGroups' + +describe('pxeFunctionalGroupsSchema', () => { + const validRow = { + FUNCTIONAL_GROUP_NAME: 'slurm_node_x86_64', + GROUP_NAME: 'group1', + SERVICE_TAG: 'SVC001', + PARENT_SERVICE_TAG: '', + HOSTNAME: 'node01', + ADMIN_MAC: 'AA:BB:CC:DD:EE:01', + ADMIN_IP: '10.0.0.10', + BMC_MAC: 'AA:BB:CC:DD:EE:02', + BMC_IP: '10.0.1.10', + } + + const validData = { + pxe_mapping_file_path: '/opt/omnia/input/pxe_mapping_file.csv', + pxe_mapping_data: [validRow], + default_lease_time: '86400', + } + + it('accepts valid data', () => { + const result = pxeFunctionalGroupsSchema.safeParse(validData) + expect(result.success).toBe(true) + }) + + it('rejects invalid functional group name format', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, FUNCTIONAL_GROUP_NAME: 'invalid_name' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects functional group name starting with number', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, FUNCTIONAL_GROUP_NAME: '1_bad_x86_64' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects empty pxe_mapping_file_path', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_file_path: '', + }) + expect(result.success).toBe(false) + }) + + it('rejects empty pxe_mapping_data', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing GROUP_NAME', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, GROUP_NAME: '' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing SERVICE_TAG', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, SERVICE_TAG: '' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing HOSTNAME', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, HOSTNAME: '' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing ADMIN_MAC', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, ADMIN_MAC: '' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects missing BMC_MAC', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + pxe_mapping_data: [{ ...validRow, BMC_MAC: '' }], + }) + expect(result.success).toBe(false) + }) + + it('rejects lease time below minimum', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + default_lease_time: '100', + }) + expect(result.success).toBe(false) + }) + + it('rejects lease time above maximum', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + default_lease_time: '99999999', + }) + expect(result.success).toBe(false) + }) + + it('accepts valid cloud init file path', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + additional_cloud_init_config_file: '/path/to/cloud_init.yml', + }) + expect(result.success).toBe(true) + }) + + it('rejects cloud init file without yml/yaml extension', () => { + const result = pxeFunctionalGroupsSchema.safeParse({ + ...validData, + additional_cloud_init_config_file: '/path/to/config.json', + }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts new file mode 100644 index 0000000000..4a8654ae21 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/buildStreamGitLab.ts @@ -0,0 +1,66 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts new file mode 100644 index 0000000000..5dc2f606f4 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/cloudInitConfig.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts new file mode 100644 index 0000000000..335a342695 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/common.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// 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_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 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]?)$/; +export const KEY_PATH_PATTERN = /^$|^\/[a-zA-Z0-9/._-]*\.key$/; +// 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]; + diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts new file mode 100644 index 0000000000..b64997ccca --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/deploymentConfigs.ts @@ -0,0 +1,91 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts new file mode 100644 index 0000000000..efb7ab77d4 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/index.ts @@ -0,0 +1,27 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// export used +export { pxeFunctionalGroupsSchema, type PxeFunctionalGroupsFormData } from './pxeFunctionalGroups'; +export { deploymentConfigsSchema, type DeploymentConfigsFormData } from './deploymentConfigs'; +export { buildStreamGitLabSchema, type BuildStreamGitLabFormData } from './buildStreamGitLab'; +export { 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'; +export { magellanDiscoverySchema, type AdminInventoryRow, type MagellanDiscoveryFormData } from './magellanDiscovery'; + +// Re-export common patterns +export * from './common'; + diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts new file mode 100644 index 0000000000..a7be6154c0 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/localRepoUserRegistry.ts @@ -0,0 +1,125 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { z } from 'zod'; +import { HOST_PORT_PATTERN, CERT_PATH_PATTERN, KEY_PATH_PATTERN } from './common'; + +// Shared schemas for repository entries +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'], + } +); + +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 +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(), + }); +} + diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/magellanDiscovery.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/magellanDiscovery.ts new file mode 100644 index 0000000000..7a4f23a8e8 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/magellanDiscovery.ts @@ -0,0 +1,35 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { z } from 'zod'; + +// Admin Inventory Row schema for Magellan discovery +const adminInventoryRowSchema = z.object({ + SERVICE_TAG: z.string().min(1, 'SERVICE_TAG/BMC_MAC is required'), + GROUP_NAME: z.string().optional().default(''), + FUNCTIONAL_GROUP_NAME: z.string().optional().default(''), + ROW: z.string().optional().default(''), + RACK: z.string().optional().default(''), + SLOT: z.string().optional().default(''), + RANGE: z.string().optional().default(''), +}); + +// Magellan Discovery Schema +export const magellanDiscoverySchema = z.object({ + admin_inventory_path: z.string().min(1, 'Admin inventory path is required'), + admin_inventory_data: z.array(adminInventoryRowSchema) + .min(1, 'At least one inventory row is required'), +}); + +export type AdminInventoryRow = z.infer; +export type MagellanDiscoveryFormData = z.infer; diff --git a/src/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts new file mode 100644 index 0000000000..6ae2dc9db1 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/omniaHaDiscoveryConfig.ts @@ -0,0 +1,151 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts new file mode 100644 index 0000000000..3048fdfcae --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/pxeFunctionalGroups.ts @@ -0,0 +1,55 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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().min(1, 'GROUP_NAME is required'), + SERVICE_TAG: z.string().min(1, 'SERVICE_TAG is required'), + PARENT_SERVICE_TAG: z.string().optional().default(''), + HOSTNAME: z.string().min(1, 'HOSTNAME is required'), + ADMIN_MAC: z.string().min(1, 'ADMIN_MAC is required'), + ADMIN_IP: z.string().min(1, 'ADMIN_IP is required'), + BMC_MAC: z.string().min(1, 'BMC_MAC is required'), + BMC_IP: z.string().min(1, 'BMC_IP is required'), + 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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts new file mode 100644 index 0000000000..a9a900f524 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/storageConfig.ts @@ -0,0 +1,303 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts new file mode 100644 index 0000000000..5b5a61e95e --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/schemas/telemetryConfigStorage.ts @@ -0,0 +1,334 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx new file mode 100644 index 0000000000..1585f66df4 --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/steps/build-stream/BuildStreamGitLabStep.tsx @@ -0,0 +1,271 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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/src/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx b/src/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx new file mode 100644 index 0000000000..cd544a2d3d --- /dev/null +++ b/src/utils/gui/frontend/src/features/configuration-wizard/steps/cloud-init/CloudInitCommonTab.tsx @@ -0,0 +1,152 @@ +// Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +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}} +
+
+
+
+ +