diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea78..e35d0b74 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,8 @@ --- -name: Bug report -about: Create a report to help us improve +name: Bug Report +about: Report a bug with Azure IPAM title: '' -labels: '' +labels: bug assignees: '' --- @@ -12,27 +12,24 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error + +1. Navigate to '...' +2. Click on '...' +3. Observe '...' **Expected behavior** A clear and concise description of what you expected to happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. +**Screenshots / Error Messages** +If applicable, add screenshots of the UI or error messages from the browser developer tools (Console or Network tab) and/or the App Service application log. -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] +**Environment:** -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +- Azure IPAM Version: [e.g. 3.6.0] +- Deployment Type: [e.g. App Service, Function App] +- Container Variant: [e.g. Debian, RHEL] +- Deployment Method: [e.g. PowerShell script, Bicep, Docker Compose] +- Browser: [e.g. Chrome, Edge, Firefox] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7d..1ee06025 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,23 @@ --- -name: Feature request -about: Suggest an idea for this project +name: Feature Request +about: Suggest an improvement or new feature for Azure IPAM title: '' -labels: '' +labels: enhancement assignees: '' --- +**Component** +Which area of Azure IPAM does this relate to? [e.g. UI, Engine/API, Deployment, Documentation] + **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +A clear and concise description of what the problem is. Ex. When managing large address spaces, I find it difficult to [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +A clear and concise description of any alternative solutions or workarounds you've considered. **Additional context** Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/azure-ipam-testing.yml b/.github/workflows/azure-ipam-testing.yml index 396283ad..5e1c2892 100644 --- a/.github/workflows/azure-ipam-testing.yml +++ b/.github/workflows/azure-ipam-testing.yml @@ -6,6 +6,17 @@ on: pull_request: branches: - main + paths-ignore: + - '.vscode/**' + - 'docs/**' + - 'examples/**' + - 'migrate/**' + - '.dockerignore' + - '.env.example' + - '.gitattributes' + - '.gitignore' + - 'LICENSE' + - '*.md' env: ACR_NAME: ${{ vars.IPAM_TEST_ACR }} @@ -49,7 +60,7 @@ jobs: - name: Build Azure IPAM Container id: buildContainer run: | - az acr build -r $ACR_NAME -t ipam:${{ github.run_id }}-${{ github.run_attempt }} -f ./Dockerfile.deb . + az acr build -r $ACR_NAME -t ipam:${{ github.run_id }}-${{ github.run_attempt }} -f ./Dockerfile.deb . --build-arg PROD_BUILD=false - name: Update Bicep File id: updateBicep diff --git a/.github/workflows/azure-ipam-version.yml b/.github/workflows/azure-ipam-version.yml index 1da68505..0ad02258 100644 --- a/.github/workflows/azure-ipam-version.yml +++ b/.github/workflows/azure-ipam-version.yml @@ -6,6 +6,18 @@ on: push: branches: - main + paths-ignore: + - '.vscode/**' + - 'docs/**' + - 'examples/**' + - 'migrate/**' + - 'tests/**' + - '.dockerignore' + - '.env.example' + - '.gitattributes' + - '.gitignore' + - 'LICENSE' + - '*.md' permissions: id-token: write diff --git a/.gitignore b/.gitignore index f27df8d4..de421b87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Global .env .eslintcache +.venv # Root Project .VSCodeCounter diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..12beb8d9 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,11 @@ +{ + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD028": false, + "MD033": { + "allowed_elements": ["u", "sup", "small", "span"] + }, + "MD060": false +} diff --git a/Dockerfile.deb b/Dockerfile.deb index 83f4990e..3c2ce4eb 100644 --- a/Dockerfile.deb +++ b/Dockerfile.deb @@ -21,8 +21,8 @@ ENV NPM_CONFIG_UPDATE_NOTIFIER=false # Set the Working Directory WORKDIR /tmp -# Copy UI Code -COPY ./ui/. ./ +# Copy UI Manifests +COPY ./ui/package*.json ./ # Install UI Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ @@ -30,7 +30,9 @@ RUN if [ "${PROD_BUILD}" = true ]; then \ else \ npm install; \ fi -RUN chmod 777 -R node_modules + +# Copy UI Source Code +COPY ./ui/. ./ # Build IPAM UI RUN npm run build @@ -46,19 +48,23 @@ ARG PORT # Set Debian Frontend to Non-Interactive ARG DEBIAN_FRONTEND=noninteractive +# Disable PIP Root Warnings +ARG PIP_ROOT_USER_ACTION=ignore + +# Disable PIP Upgrade Warnings +ARG PIP_DISABLE_PIP_VERSION_CHECK=1 + # Set Environment Variable ENV PORT=${PORT} -# Disable PIP Root Warnings -ENV PIP_ROOT_USER_ACTION=ignore - # Set Working Directory WORKDIR /tmp # Install OpenSSH and set the password for root to "Docker!" -RUN apt-get update -RUN apt-get install -qq openssh-server -y \ - && echo "root:Docker!" | chpasswd +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-server \ + && echo "root:Docker!" | chpasswd \ + && rm -rf /var/lib/apt/lists/* # Enable SSH root login with Password Authentication # RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config @@ -67,8 +73,7 @@ RUN apt-get install -qq openssh-server -y \ COPY sshd_config /etc/ssh/ # Set SSH Key Permissions -RUN ssh-keygen -A -RUN mkdir -p /var/run/sshd +RUN ssh-keygen -A && mkdir -p /var/run/sshd # Set Working Directory WORKDIR /ipam @@ -77,9 +82,6 @@ WORKDIR /ipam COPY ./engine/requirements.txt /code/requirements.txt COPY ./engine/requirements.lock.txt /code/requirements.lock.txt -# Upgrade PIP -RUN pip install --upgrade pip --progress-bar off - # Install Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ pip install --no-cache-dir -r /code/requirements.lock.txt --progress-bar off; \ @@ -101,4 +103,4 @@ RUN chmod +x init.sh EXPOSE $PORT 2222 # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT exec ./init.sh ${PORT} diff --git a/Dockerfile.func b/Dockerfile.func index e1f86404..aa553f34 100644 --- a/Dockerfile.func +++ b/Dockerfile.func @@ -18,8 +18,8 @@ ENV NPM_CONFIG_UPDATE_NOTIFIER=false # Set the Working Directory WORKDIR /tmp -# Copy UI Code -COPY ./ui/. ./ +# Copy UI Manifests +COPY ./ui/package*.json ./ # Install UI Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ @@ -27,7 +27,9 @@ RUN if [ "${PROD_BUILD}" = true ]; then \ else \ npm install; \ fi -RUN chmod 777 -R node_modules + +# Copy UI Source Code +COPY ./ui/. ./ # Build IPAM UI RUN npm run build @@ -40,13 +42,16 @@ ARG PROD_BUILD # Set Debian Frontend to Non-Interactive ARG DEBIAN_FRONTEND=noninteractive +# Disable PIP Root Warnings +ARG PIP_ROOT_USER_ACTION=ignore + +# Disable PIP Upgrade Warnings +ARG PIP_DISABLE_PIP_VERSION_CHECK=1 + # Set Azure Function Root Directory & Enable Console Logging ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ AzureFunctionsJobHost__Logging__Console__IsEnabled=true -# Disable PIP Root Warnings -ENV PIP_ROOT_USER_ACTION=ignore - # Set Working Directory WORKDIR /tmp @@ -54,9 +59,6 @@ WORKDIR /tmp COPY ./engine/requirements.txt . COPY ./engine/requirements.lock.txt . -# Upgrade PIP -RUN pip install --upgrade pip --progress-bar off - # Install Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off; \ diff --git a/Dockerfile.rhel b/Dockerfile.rhel index 50b85608..5044cd76 100644 --- a/Dockerfile.rhel +++ b/Dockerfile.rhel @@ -1,5 +1,5 @@ -ARG BUILD_IMAGE=registry.access.redhat.com/ubi8/nodejs-22 -ARG SERVE_IMAGE=registry.access.redhat.com/ubi8/python-311 +ARG BUILD_IMAGE=registry.access.redhat.com/ubi9/nodejs-22 +ARG SERVE_IMAGE=registry.access.redhat.com/ubi9/python-311 # Set Production Build Flag ARG PROD_BUILD=true @@ -21,8 +21,8 @@ WORKDIR /tmp # Switch to Root User USER root -# Copy UI Code -COPY ./ui/. ./ +# Copy UI Manifests +COPY ./ui/package*.json ./ # Install UI Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ @@ -30,7 +30,9 @@ RUN if [ "${PROD_BUILD}" = true ]; then \ else \ npm install; \ fi -RUN chmod 777 -R node_modules + +# Copy UI Source Code +COPY ./ui/. ./ # Build IPAM UI RUN npm run build @@ -43,26 +45,30 @@ ARG PROD_BUILD # Port to Listen On ARG PORT +# Disable PIP Root Warnings +ARG PIP_ROOT_USER_ACTION=ignore + +# Disable PIP Upgrade Warnings +ARG PIP_DISABLE_PIP_VERSION_CHECK=1 + # Set Environment Variable ENV PORT=${PORT} -# Disable PIP Root Warnings -ENV PIP_ROOT_USER_ACTION=ignore - # Set Working Directory WORKDIR /tmp # Switch to Root User USER root -# Disable Subscription Manager YUM Plugin -RUN sed -i s/enabled=./enabled=0/g /etc/yum/pluginconf.d/subscription-manager.conf +# Disable Subscription Manager DNF Plugin +RUN sed -i s/enabled=./enabled=0/g /etc/dnf/plugins/subscription-manager.conf # Install OpenSSH and set the password for root to "Docker!" -RUN yum update -y -RUN yum install -qq openssh-server -y \ - && echo "root:Docker!" | chpasswd \ - && systemctl enable sshd +RUN dnf update -y \ + && dnf install -y openssh-server \ + && echo "root:Docker!" | chpasswd \ + && dnf clean all \ + && rm -rf /var/cache/dnf # Enable SSH root login with Password Authentication # RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config @@ -70,8 +76,7 @@ RUN yum install -qq openssh-server -y \ # Copy 'sshd_config File' to /etc/ssh/ COPY sshd_config /etc/ssh/ -RUN ssh-keygen -A -RUN mkdir /var/run/sshd +RUN ssh-keygen -A && mkdir /var/run/sshd # Set Working Directory WORKDIR /ipam @@ -80,9 +85,6 @@ WORKDIR /ipam COPY ./engine/requirements.txt /code/requirements.txt COPY ./engine/requirements.lock.txt /code/requirements.lock.txt -# Upgrade PIP -RUN pip install --upgrade pip --progress-bar off - # Install Dependencies RUN if [ "${PROD_BUILD}" = true ]; then \ pip install --no-cache-dir -r /code/requirements.lock.txt --progress-bar off; \ @@ -109,4 +111,4 @@ USER 1001 EXPOSE $PORT 2222 # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT exec ./init.sh ${PORT} diff --git a/README.md b/README.md index 1c70182b..e6f6cfac 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,3 @@ - - - - - - # Azure IPAM Azure IPAM is a lightweight solution developed on top of the Azure platform designed to help Azure customers manage their IP Address space easily and effectively. @@ -31,14 +6,13 @@ Azure IPAM is a lightweight solution developed on top of the Azure platform desi | File/folder | Description | |----------------------|---------------------------------------------------------------| -| `.github/` | Bug Report, Issue Templates and GitHub Actions | +| `.github/` | Issue/Feature Templates and GitHub Actions | | `.vscode/` | VSCode Configuration | | `deploy/` | Deployment Bicep Templates & PowerShell Deployment Script | -| `assets/` | Compiled ZIP Archive | | `docs/` | Documentation Folder | | `engine/` | Engine Application Code | -| `examples/` | Example Templates, Scripts and Code Snippets for Azure IPAM | -| `migrate/` | Migration Bicep Templates & Powershell Migration Script | +| `examples/` | Example IaC Templates, Scripts, and Code Snippets | +| `migrate/` | Migration Bicep Templates & PowerShell Migration Script | | `lb/` | Load Balancer (NGINX) Configs | | `tests/` | Testing Scripts | | `tools/` | Lifecycle Scripts (Build/Version/Update) | @@ -61,7 +35,7 @@ Azure IPAM is a lightweight solution developed on top of the Azure platform desi ## Documentation -IPAM uses both [Docsify](https://docsify.js.org/) and [GitHub Pages](https://docs.github.com/en/github/working-with-github-pages) for all [project documentation](https://azure.github.io/ipam/). +IPAM uses both [Docsify](https://docsify.js.org/) and [GitHub Pages](https://docs.github.com/en/pages) for all [project documentation](https://azure.github.io/ipam/). ## Questions or Comments for the team? @@ -70,23 +44,23 @@ The IPAM team welcomes questions and contributions from the community. We have s ## FAQ **Why should I use IPAM?** -You realize that you do not have a clear picture as to what is deployed into your Azure environment and connected to your private IP address space. Or, you would like a way to easily manage, assign, and track your private IP addess space usage! +You realize that you do not have a clear picture as to what is deployed into your Azure environment and connected to your private IP address space. Or, you would like a way to easily manage, assign, and track your private IP address space usage! **What does the roadmap for IPAM look like?** - We are assessing leveraging Azure Container Apps for hosting the two containers that make up the IPAM application - We are assessing support for multiple Tenants, as today the tool is designed with a single Tenant in mind -- We are working on capturing IP address infromation for resources that support hybrid connectivity (ie Gateways) +- We are working on capturing IP address information for resources that support hybrid connectivity (ie Gateways) -**Who are the awesome people that built this solution??** +**Who built this solution?** -Matt and Harvey are Architects at Microsoft! We are always on the look out for interesting ways to help our customers overcome their challenges! +Azure IPAM was created by Matt and Harvey, Cloud Solution Architects at Microsoft. ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. +the rights to use your contribution. For details, visit the [Microsoft CLA](https://cla.opensource.microsoft.com). When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 index 0bc741dd..244de81b 100644 --- a/deploy/deploy.ps1 +++ b/deploy/deploy.ps1 @@ -398,6 +398,32 @@ process { return $validLocations.Contains($Location) } + Function Get-AccessToken { + Param( + [Parameter(Mandatory = $false)] + [string]$Resource, + [Parameter(Mandatory = $false)] + [switch]$AsPlainText + ) + + $params = @{} + if ($Resource) { $params['ResourceUrl'] = $Resource } + + $token = (Get-AzAccessToken @params).Token + + if ($AsPlainText) { + if ($token -is [System.Security.SecureString]) { + return ConvertFrom-SecureString $token -AsPlainText -Force + } + return $token + } else { + if ($token -isnot [System.Security.SecureString]) { + return ConvertTo-SecureString $token -AsPlainText -Force + } + return $token + } + } + Function Get-BuildLogs { Param( [Parameter(Mandatory = $true)] @@ -420,11 +446,7 @@ process { AZURE_CHINA = "management.chinacloudapi.cn" } - $accessToken = (Get-AzAccessToken).Token - - if ($accessToken -isnot [System.Security.SecureString]) { - $accessToken = ConvertTo-SecureString $accessToken -AsPlainText -Force - } + $accessToken = Get-AccessToken $response = Invoke-RestMethod ` -Method POST ` @@ -700,19 +722,7 @@ process { ) # Get Microsoft Graph Access Token - $accesstoken = (Get-AzAccessToken -Resource "https://$($msGraphMap[$AzureCloud].Endpoint)/").Token - - # Switch Access Token to SecureString if Graph Version is 2.x - $graphVersion = [System.Version](Get-InstalledModule -Name Microsoft.Graph -ErrorAction SilentlyContinue | Sort-Object -Property Version | Select-Object -Last 1).Version ` - ?? (Get-Module -Name Microsoft.Graph -ErrorAction SilentlyContinue | Sort-Object -Property Version | Select-Object -Last 1).Version ` - ?? (Get-Module -Name Microsoft.Graph -ListAvailable -ErrorAction SilentlyContinue | Sort-Object -Property Version | Select-Object -Last 1).Version ` - ?? [System.Version]([array](Get-InstalledModule | Where-Object { $_.Name -like "Microsoft.Graph.*" } | Select-Object -ExpandProperty Version | Sort-Object | Get-Unique))[0] - - if ($graphVersion.Major -gt 1) { - if ($accesstoken -isnot [System.Security.SecureString]) { - $accesstoken = ConvertTo-SecureString $accesstoken -AsPlainText -Force - } - } + $accesstoken = Get-AccessToken -Resource "https://$($msGraphMap[$AzureCloud].Endpoint)/" Write-Host "INFO: Logging in to Microsoft Graph" -ForegroundColor Green @@ -996,11 +1006,7 @@ process { $publishSuccess = $False if ($UseAPI) { - $accessToken = (Get-AzAccessToken).Token - - if ($accessToken -is [System.Security.SecureString]) { - $accessToken = ConvertFrom-SecureString $accessToken -AsPlainText -Force - } + $accessToken = Get-AccessToken -AsPlainText $zipContents = Get-Item -Path $ZipFilePath diff --git a/deploy/main.bicep b/deploy/main.bicep index 1e1c5dcf..db0ee6e2 100644 --- a/deploy/main.bicep +++ b/deploy/main.bicep @@ -151,7 +151,7 @@ module appService './modules/appService.bicep' = if (!deployAsFunc) { workspaceId: logAnalyticsWorkspace.outputs.workspaceId deployAsContainer: deployAsContainer privateAcr: privateAcr - privateAcrUri: privateAcr ? containerRegistry.outputs.acrUri : '' + privateAcrUri: privateAcr ? containerRegistry!.outputs.acrUri : '' } } @@ -174,7 +174,7 @@ module functionApp './modules/functionApp.bicep' = if (deployAsFunc) { workspaceId: logAnalyticsWorkspace.outputs.workspaceId deployAsContainer: deployAsContainer privateAcr: privateAcr - privateAcrUri: privateAcr ? containerRegistry.outputs.acrUri : '' + privateAcrUri: privateAcr ? containerRegistry!.outputs.acrUri : '' } } @@ -183,6 +183,6 @@ output suffix string = uniqueString(guid) output subscriptionId string = subscription().subscriptionId output resourceGroupName string = resourceGroup.name output appServiceName string = deployAsFunc ? resourceNames.functionName : resourceNames.appServiceName -output appServiceHostName string = deployAsFunc ? functionApp.outputs.functionAppHostName : appService.outputs.appServiceHostName -output acrName string = privateAcr ? containerRegistry.outputs.acrName : '' -output acrUri string = privateAcr ? containerRegistry.outputs.acrUri : '' +output appServiceHostName string = deployAsFunc ? functionApp!.outputs.functionAppHostName : appService!.outputs.appServiceHostName +output acrName string = privateAcr ? containerRegistry!.outputs.acrName : '' +output acrUri string = privateAcr ? containerRegistry!.outputs.acrUri : '' diff --git a/deploy/modules/keyVault.bicep b/deploy/modules/keyVault.bicep index 078ba583..e2a4fdd1 100644 --- a/deploy/modules/keyVault.bicep +++ b/deploy/modules/keyVault.bicep @@ -34,6 +34,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { name: keyVaultName location: location properties: { + enableSoftDelete: true enablePurgeProtection: true enableRbacAuthorization: true tenantId: tenantId diff --git a/deploy/update.ps1 b/deploy/update.ps1 index b2ebd46f..d6e9d4d8 100644 --- a/deploy/update.ps1 +++ b/deploy/update.ps1 @@ -106,6 +106,32 @@ $containerBuildError = $false $TempFolderObj = $null +Function Get-AccessToken { + Param( + [Parameter(Mandatory = $false)] + [string]$Resource, + [Parameter(Mandatory = $false)] + [switch]$AsPlainText + ) + + $params = @{} + if ($Resource) { $params['ResourceUrl'] = $Resource } + + $token = (Get-AzAccessToken @params).Token + + if ($AsPlainText) { + if ($token -is [System.Security.SecureString]) { + return ConvertFrom-SecureString $token -AsPlainText -Force + } + return $token + } else { + if ($token -isnot [System.Security.SecureString]) { + return ConvertTo-SecureString $token -AsPlainText -Force + } + return $token + } +} + Function Get-BuildLogs { Param( [Parameter(Mandatory=$true)] @@ -126,7 +152,7 @@ Function Get-BuildLogs { AZURE_CHINA = "management.chinacloudapi.cn" }; - $accessToken = (Get-AzAccessToken).Token | ConvertTo-SecureString -AsPlainText + $accessToken = Get-AccessToken $response = Invoke-RestMethod ` -Method POST ` @@ -280,7 +306,7 @@ Function Publish-ZipFile { $publishSuccess = $False if ($UseAPI) { - $accessToken = (Get-AzAccessToken).Token + $accessToken = Get-AccessToken -AsPlainText $zipContents = Get-Item -Path $ZipFilePath $publishProfile = Get-AzWebAppPublishingProfile -Name $AppName -ResourceGroupName $ResourceGroupName diff --git a/docs/README.md b/docs/README.md index 518b39b9..efa2cce5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,20 +1,12 @@ # Welcome to Azure IPAM - - ## Overview and Architecture -Azure IPAM was developed to give customers a simple, straightforward way to manage their IP address space in Azure. It enables end-to-end planning, deploying, managing and monitoring of your IP address space, with an intuitive user experience. Additionally, it can automatically discover IP address utilization within your Azure tenant and enables you to manage it all from a centralized UI. You can also interface with the Azure IPAM service programmatically via a RESTful API to facilitate IP address management at scale via Infrastructure as Code (IaC) and CI/CD pipelines. Azure IPAM is designed and architected based on the 5 pillars of the [Microsoft Azure Well Architected Framework](https://docs.microsoft.com/azure/architecture/framework/). +Azure IPAM was developed to give customers a simple, straightforward way to manage their IP address space in Azure. It enables end-to-end planning, deploying, managing and monitoring of your IP address space, with an intuitive user experience. Additionally, it can automatically discover IP address utilization within your Azure tenant and enables you to manage it all from a centralized UI. You can also interface with the Azure IPAM service programmatically via a RESTful API to facilitate IP address management at scale via Infrastructure as Code (IaC) and CI/CD pipelines. Azure IPAM is designed and architected based on the 5 pillars of the [Microsoft Azure Well-Architected Framework](https://learn.microsoft.com/azure/well-architected/). -| App Service | Function | +| App Service Deployment | Function Deployment | |-----------------------------------------------------------------:|:---------------------------------------------------------------------------| -| ![IPAM Architecture](./images/ipam_architecture_full.png ':size=70%') | ![IPAM Architecture](./images/ipam_architecture_function.png ':size=70%') | +| ![App Service Architecture](./images/ipam_architecture_full.png ':size=70%') | ![Function Architecture](./images/ipam_architecture_function.png ':size=70%') | ## Azure IPAM Infrastructure @@ -25,12 +17,12 @@ Here is a more specific breakdown of the components used: - **App Registrations** - 2x App Registrations - *Engine* App Registration - - Granted **reader** permission to the [Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) to facilitate IPAM Admin operations (global visibility) - - Authentication point for IPAM API operations ([on-behalf-of](https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow) flow) + - Granted **Reader** permission to the [Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) to facilitate IPAM Admin operations (global visibility) + - Authentication point for IPAM API operations ([on-behalf-of](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow) flow) - *UI* App Registration *(Optional if no UI is desired)* - Granted **read** permissions for Microsoft Graph API's - Added as a *known client application* for the *Engine* App Registration - - Authentication point for the IPAM UI ([auth code](https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow) flow) + - Authentication point for the IPAM UI ([auth code](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow) flow) - **Resource Group** - Contains all Azure IPAM deployed resources - **App Service Plan with App Service** *(AppContainer Deployment only)* @@ -41,11 +33,14 @@ Here is a more specific breakdown of the components used: - Storage for the Azure Function metadata - **Cosmos DB** - Backend NoSQL datastore for the IPAM application -- **KeyVault** +- **Key Vault** - Stores the following secrets: - - App Registration application IDs and Secrets (Engine & UI) + - Engine App Registration ID and Secret + - UI App Registration ID - Managed Identity ID - Azure Tenant ID +- **Log Analytics Workspace** + - Collects diagnostic logs from Key Vault, Cosmos DB, and App Service / Function App - **User Assigned Managed Identity** - Assigned to the App Service to retrieve secrets from KeyVault - **Container Registry** *(Optional)* @@ -53,24 +48,25 @@ Here is a more specific breakdown of the components used: ## How Azure IPAM Works -Azure IPAM has been designed as such to radically simplify the often daunting task of IP address management within Azure and was built to accommodate use cases such as the following... +Azure IPAM is designed to simplify the often daunting task of IP address management within Azure and was built to accommodate use cases such as the following... - Discover - Identify networks, subnets and endpoints holistically across your Azure tenant - Visualize misconfigurations such as orphaned endpoints and improperly configured virtual network peers - Organize - - Group Azure networks into *Spaces* and *Blocks* aligned to internal lines of business and enterprise CIDR assignments + - Group Azure networks into [*Spaces*](./how-to/README.md#spaces) and [*Blocks*](./how-to/README.md#blocks) aligned to internal lines of business and enterprise CIDR assignments - Track IP and CIDR consumption - - Map external (non-Azure) networks to Azure CIDR ranges + - Map [external (non-Azure) networks](./how-to/README.md#external-networks) to Azure CIDR ranges, including on-premises datacenters, co-location facilities, and other cloud providers + - Track external subnets and individual endpoints for complete IP address visibility - Plan - Explore "what if" cases such as how may subnets of a given mask are available within a given CIDR block - Self-Service - - Allow users to reserve CIDR blocks for new virtual network and subnet creation programatically + - Allow users to reserve CIDR blocks for new virtual network and subnet creation programmatically - Integration with Azure template deployments (ARM/Bicep), Terraform and CI/CD pipelines ## User Interface -The front end is written in [React](https://reactjs.org/) and leverages the [Material UI](https://mui.com/) for the UI components. The UI handles AuthN/AuthZ with AzureAD via [MSAL](https://learn.microsoft.com/azure/active-directory/develop/msal-overview), and manages token acquisition & refresh for communication to the backend Engine API (on your behalf). +The front end is written in [React](https://react.dev/) and leverages the [Material UI](https://mui.com/) for the UI components. The UI handles AuthN/AuthZ with Microsoft Entra ID via [MSAL](https://learn.microsoft.com/entra/identity-platform/msal-overview), and manages token acquisition and refresh for communication to the backend Engine API on the user's behalf. ## Backend Engine diff --git a/docs/_sidebar.md b/docs/_sidebar.md index be938c22..1f5c3a0c 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -3,10 +3,11 @@ - [Welcome](/README.md) - [Deployment](/deployment/README.md) +- [How-To](/how-to/README.md) - [Update](/update/README.md) - [Migration](/migration/README.md) - [Troubleshooting](/troubleshooting/README.md) -- [How-To](/how-to/README.md) - [API](/api/README.md) +- [Automation](/automation/README.md) - [Questions/Comments](/questions-comments/README.md) - [Contributing](/contributing/README.md) diff --git a/docs/api/README.md b/docs/api/README.md index 561bfbb7..3763e911 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -24,24 +24,503 @@ You can also retrieve an Azure AD token from Azure IPAM via Azure PowerShell by ![IPAM API Resource URL](./images/ipam_api_resource_url.png) -```ps1 -$accessToken = ConvertTo-SecureString (Get-AzAccessToken -ResourceUrl api://e3ff2k34-2271-58b5-9g2g-5004145608b3).Token -AsPlainText +```powershell +$accessToken = (Get-AzAccessToken -ResourceUrl api://e3ff2k34-2271-58b5-9g2g-5004145608b3).Token ``` -## Sample API Calls +> **Note:** As of [Azure PowerShell v14](https://learn.microsoft.com/powershell/azure/release-notes-azureps#1400---may-2025), `Get-AzAccessToken` returns the `.Token` property as a `SecureString`, which is the expected type for `Invoke-RestMethod -Token`. If you are using an earlier version, wrap the result with `ConvertTo-SecureString ... -AsPlainText -Force`. + +## Spaces + +Spaces are the top-level organizational unit in Azure IPAM. Each Space represents a logical grouping of non-overlapping IP address Blocks. For more on what Spaces are and how to manage them via the UI, see the [Spaces](/how-to/README.md#spaces) section of the How-To documentation. + +All Space management endpoints (create, update, delete) are restricted to Azure IPAM administrators. + +### Space Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces` | List all Spaces | +| `POST` | `/spaces` | Create a new Space | +| `GET` | `/spaces/{space}` | Get details of a specific Space | +| `PATCH` | `/spaces/{space}` | Update a Space (JSON Patch) | +| `DELETE` | `/spaces/{space}` | Delete a Space | + +> **Note:** The `GET` endpoints accept optional `expand` (admin-only) and `utilization` query parameters. Setting `utilization=true` includes size and used address counts. Setting `expand=true` expands all nested network references. + +### Example API Calls + +```powershell +$engineClientId = '' +$appName = 'ipamdev' + +$accessToken = (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token + +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} +``` + +#### List All Spaces + +```powershell +# Get all Spaces +$spaces = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Get all Spaces with utilization data +$spacesUtil = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces?utilization=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Get a Specific Space + +```powershell +$space = 'TestSpace' + +$spaceDetails = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Create a Space + +Creating a Space requires a name and description. The name must be 1–64 characters and can contain alphanumerics, underscores, hyphens, and periods. + +```powershell +$body = @{ + name = 'ProductionSpace' + desc = 'Production environment IP address space' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Update a Space + +You can update a Space's name or description using a JSON Patch. Only the `replace` operation is supported, and allowed paths are `/name` and `/desc`. + +```powershell +$space = 'ProductionSpace' + +$body = @( + @{ + op = 'replace' + path = '/desc' + value = 'Updated production IP space description' + } +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Patch' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Delete a Space + +Deleting a Space will fail if it contains Blocks unless you pass the `force` query parameter. + +```powershell +$space = 'ProductionSpace' + +# Delete (will fail if Blocks exist) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Force delete (removes the Space and all its Blocks) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space`?force=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +## Blocks + +Blocks represent IPv4 CIDR ranges within a Space. Each Block can contain virtual network associations, CIDR reservations, and external networks. For more on what Blocks are and how to manage them via the UI, see the [Blocks](/how-to/README.md#blocks) section of the How-To documentation. + +All Block management endpoints (create, update, delete) are restricted to Azure IPAM administrators. + +### Block Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/blocks` | List all Blocks in a Space | +| `POST` | `/spaces/{space}/blocks` | Create a new Block | +| `GET` | `/spaces/{space}/blocks/{block}` | Get details of a specific Block | +| `PATCH` | `/spaces/{space}/blocks/{block}` | Update a Block (JSON Patch) | +| `DELETE` | `/spaces/{space}/blocks/{block}` | Delete a Block | +| `GET` | `/spaces/{space}/blocks/{block}/available` | List virtual networks eligible for association | + +> **Note:** The `GET` endpoints accept optional `expand` (admin-only) and `utilization` query parameters. Setting `utilization=true` includes size and used address counts. Setting `expand=true` expands virtual network references to full network objects. Non-admin users receive a filtered view — only reservations they created are included. + +### Example API Calls + +```powershell +$engineClientId = '' +$appName = 'ipamdev' +$space = 'TestSpace' + +$accessToken = (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token + +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} +``` + +#### List All Blocks in a Space + +```powershell +# Get all Blocks +$blocks = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Get all Blocks with utilization data +$blocksUtil = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks?utilization=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Get a Specific Block + +```powershell +$block = 'TestBlock' + +$blockDetails = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Create a Block + +Creating a Block requires a name and a valid IPv4 CIDR range. The CIDR cannot overlap with existing Blocks in the same Space. + +```powershell +$block = @{ + name = 'ProductionBlock' + cidr = '10.0.0.0/16' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $block +``` + +#### Update a Block + +You can update a Block's name or CIDR using a JSON Patch. Only the `replace` operation is supported, and allowed paths are `/name` and `/cidr`. When updating the CIDR, the new range must still contain all currently associated virtual networks, reservations, and external networks. + +```powershell +$block = 'ProductionBlock' + +$body = @( + @{ + op = 'replace' + path = '/cidr' + value = '10.0.0.0/15' + } +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Patch' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Delete a Block + +Deleting a Block will fail if it contains virtual network associations or active reservations unless you pass the `force` query parameter. + +```powershell +$block = 'ProductionBlock' + +# Delete (will fail if associations or reservations exist) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Force delete (removes the Block and all its data) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block`?force=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### List Available Networks for a Block + +Query which virtual networks are eligible for association with a given Block. This is useful before creating associations. + +```powershell +$block = 'ProductionBlock' + +# Get available networks with full details +$available = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/available?expand=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +## Virtual Network Associations + +Virtual Network Associations map Azure virtual networks (and virtual hubs) to Blocks within Azure IPAM. Associating a network tells Azure IPAM that its address space is allocated from the Block's CIDR range, which drives utilization tracking and overlap prevention. All association management endpoints are restricted to Azure IPAM administrators. + +For more information on what Virtual Network Associations are, how eligibility rules work, and how to manage them via the UI, please see the [Virtual Network Associations](/how-to/README.md#virtual-network-associations) section of the How-To documentation. + +The API base path for Virtual Network Association operations is: + +```text +/api/spaces/{space}/blocks/{block} +``` + +### Association Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/blocks/{block}/available` | List virtual networks eligible for association | +| `GET` | `/spaces/{space}/blocks/{block}/networks` | List currently associated virtual networks | +| `POST` | `/spaces/{space}/blocks/{block}/networks` | Add a single virtual network association | +| `PUT` | `/spaces/{space}/blocks/{block}/networks` | Replace all associations (full replacement) | +| `DELETE` | `/spaces/{space}/blocks/{block}/networks` | Remove one or more associations | + +> **Note:** The `GET /available` endpoint is accessible to all authenticated users. All other association endpoints (`GET /networks`, `POST`, `PUT`, `DELETE`) are restricted to Azure IPAM administrators and will return `403 Forbidden` for non-admin users. +> +> When an administrator calls `GET /available`, the Azure Resource Graph query runs with the application's service principal credentials, returning networks across the entire tenant. When a non-admin user calls the same endpoint, the query runs on behalf of the user (OBO), so only networks in Azure subscriptions the user has RBAC read access to are returned. + +### Query Parameters + +The `GET /available` and `GET /networks` endpoints accept an optional `expand` query parameter (default: `false`). When set to `true`, the response includes full network details (name, resource group, subscription, prefixes) rather than just resource IDs. + +### Example API Calls + +The following examples demonstrate common Virtual Network Association operations using Azure PowerShell. As with the other examples, you'll need to obtain an Azure AD token and set up your common variables first. + +```powershell +$engineClientId = '' +$appName = 'ipamdev' +$space = 'TestSpace' +$block = 'TestBlock' + +$accessToken = (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token + +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} +``` + +#### List Available Virtual Networks + +Before associating virtual networks, you can query which networks are eligible for a given Block. This returns only networks whose address space falls within the Block's CIDR range and does not overlap unfulfilled Reservations or External Networks. For non-admin users, the results are further scoped to networks in Azure subscriptions the caller has RBAC read access to (see note above). + +```powershell +# Get available networks (IDs only) +$available = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/available" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Get available networks with full details +$availableExpanded = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/available?expand=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +The expanded response includes the following fields for each network: + +```text +$availableExpanded[0] + +name : my-vnet-01 +id : /subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet-01 +prefixes : {10.1.0.0/24} +resource_group : rg-networking +subscription_id : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +tenant_id : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +#### List Current Associations + +Retrieve the virtual networks currently associated with a Block. + +```powershell +# Get current associations (IDs and active status) +$networks = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/networks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Get current associations with full details +$networksExpanded = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/networks?expand=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Add a Single Virtual Network + +Associate a single virtual network with a Block by providing its Azure resource ID. + +```powershell +$body = @{ + id = '/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-networking/providers/Microsoft.Network/virtualNetworks/my-vnet-01' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/networks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +The virtual network must meet all eligibility requirements: its address space must fall within the Block's CIDR, it must not overlap existing associations, Reservations, or External Networks, and it must not already be associated with the Block. + +#### Replace All Associations + +This is the same operation the UI performs when you click **Save**. It replaces the Block's entire association list with the provided array of resource IDs. This is useful when you want to set the exact list of associated networks in a single call. + +```powershell +$body = @( + '/subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet-01', + '/subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet-02', + '/subscriptions/.../providers/Microsoft.Network/virtualHubs/my-vhub-01' +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Put' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/networks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +The following validations are enforced: + +- No duplicate IDs in the list +- Every ID must resolve to a valid Azure virtual network or virtual hub +- Every network must have at least one address prefix within the Block's CIDR +- No CIDR overlap between networks in the list +- No CIDR overlap with unsettled Reservations or External Networks in the Block + +> **Note:** This is a full replacement operation. Any previously associated virtual networks that are not included in the new list will be disassociated. + +#### Remove Associations + +Remove one or more virtual network associations by providing an array of resource IDs to disassociate. + +```powershell +$body = @( + '/subscriptions/.../providers/Microsoft.Network/virtualNetworks/my-vnet-01' +) | ConvertTo-Json + +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/networks" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +All IDs in the list must currently exist in the Block's association list. Attempting to remove an ID that is not associated will result in an error. + +## Reservations + +Reservations allow you to claim address space within a Block before creating an Azure virtual network. For more information on what reservations are, how the lifecycle works, and how to manage them via the UI, please see the [Reservations](/how-to/README.md#reservations) section of the How-To documentation. + +The API supports creating reservations against a specific Block, or against a list of Blocks (IPAM will use the first Block with available space). + +### Reservation Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/reservations` | List reservations across all Blocks in a Space | +| `POST` | `/spaces/{space}/reservations` | Create a reservation from a list of Blocks | +| `GET` | `/spaces/{space}/blocks/{block}/reservations` | List reservations for a specific Block | +| `POST` | `/spaces/{space}/blocks/{block}/reservations` | Create a reservation in a specific Block | +| `GET` | `/spaces/{space}/blocks/{block}/reservations/{reservation}` | Get a specific reservation | +| `DELETE` | `/spaces/{space}/blocks/{block}/reservations` | Delete (cancel) multiple reservations | +| `DELETE` | `/spaces/{space}/blocks/{block}/reservations/{reservation}` | Delete (cancel) a single reservation | + +> **Note:** The `GET` endpoints accept a `settled` query parameter (default: `false`). Set it to `true` to include fulfilled and cancelled reservations in the results. Non-admin users will only see reservations they created. + +### Example API Calls You'll need to provide the following for each API call: -* Bearer Token -* HTTP Method -* API Request URL -* HTTP Headers -* Request Body (PUT/PATCH/POST) +- Bearer Token +- HTTP Method +- API Request URL +- HTTP Headers +- Request Body (POST/DELETE) Here is an example of how to create an IP address CIDR reservation in order to create a new vNET. We'll be performing a POST to the following request URL: ```text -https://ipmadev.azurewebsites.net/api/spaces/TestSpace/blocks/TestBlock/reservations +https://ipamdev.azurewebsites.net/api/spaces/TestSpace/blocks/TestBlock/reservations ``` The body contains a bit mask size of **/24**. Based on this, IPAM will provide the next available **/24** CIDR block available in the **TestBlock** found within our **TestSpace** (as denoted in our request URL). @@ -60,47 +539,436 @@ Click **Send** and you will receive a response of type **201 Created** with key ![Postman CIDR Reservation Response](./images/postman_response.png) -Here is the same example performed via Azure PowerShell. +Here is the same example performed via Azure PowerShell. First, set up the common variables and authentication: -```ps1 +```powershell $engineClientId = '' $appName = 'ipamdev' $space = 'TestSpace' $block = 'TestBlock' -$accessToken = ConvertTo-SecureString (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token -AsPlainText +$accessToken = (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token -$requestUrl = "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} +``` + +#### Create a Reservation by Size +The simplest way to create a reservation is by specifying a mask size. IPAM will find the next available CIDR of that size within the Block. + +```powershell $body = @{ - 'size' = 24 + size = 24 + desc = 'Reservation for Project Alpha vNET' } | ConvertTo-Json -$headers = @{ - 'Accept' = 'application/json' - 'Content-Type' = 'application/json' -} - $response = Invoke-RestMethod ` - -Method 'Post' ` - -Uri $requestUrl ` - -Authentication 'Bearer' ` - -Token $accessToken ` - -Headers $headers ` - -Body $body + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body ``` -The call will return key information regarding your CIDR block reservation. Again, make note of the *tag* information in the response. +The call will return key information regarding your CIDR block reservation. Make note of the *tag* information in the response — you'll need to apply it to your virtual network. -```ps1 +```text $response id : ABNsJjXXyTRDTRCdJEJThu cidr : 10.1.5.0/24 -userId : user@ipam.onmicrosoft.com +desc : Reservation for Project Alpha vNET createdOn : 1662514052.26623 status : wait tag : @{X-IPAM-RES-ID=ABNsJjXXyTRDTRCdJEJThu} ``` +You can also control how IPAM selects the available range: + +```powershell +# Allocate from the end of the Block and use the smallest fitting range +$body = @{ + size = 24 + desc = 'Reservation at end of block' + reverse_search = $true + smallest_cidr = $true +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Create a Reservation by CIDR + +If you need a specific CIDR range, provide it directly instead of a size. The CIDR must be within the Block and cannot overlap existing allocations. + +```powershell +$body = @{ + cidr = '10.1.10.0/24' + desc = 'Specific range for DMZ network' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +> **Note:** The `cidr` and `size` parameters cannot be used together. The `reverse_search` and `smallest_cidr` options are only available when using `size`. + +#### Create a Reservation from Multiple Blocks + +If you're flexible about which Block the reservation comes from, you can provide a list of Block names. IPAM will evaluate them in order and create the reservation in the first Block with available space. + +```powershell +$body = @{ + blocks = @('PrimaryBlock', 'SecondaryBlock', 'OverflowBlock') + size = 24 + desc = 'Flexible reservation across blocks' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +Note the different request URL — this uses the Space-level endpoint (`/spaces/{space}/reservations`) rather than the Block-level endpoint. + +#### List Reservations + +You can retrieve all active reservations for a Block, or across all Blocks in a Space. + +```powershell +# Get active reservations for a specific Block +$reservations = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Include settled (fulfilled/cancelled) reservations +$allReservations = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations?settled=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Get reservations across all Blocks in a Space +$spaceReservations = Invoke-RestMethod ` + -Method 'Get' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` + +#### Cancel a Reservation + +Cancelling a reservation releases the held CIDR range so it can be used for other allocations. You can cancel a single reservation or multiple at once. + +```powershell +# Cancel a single reservation by ID +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations/ABNsJjXXyTRDTRCdJEJThu" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Cancel multiple reservations at once +$body = @( + 'ABNsJjXXyTRDTRCdJEJThu', + 'CDPtKkYYzUSEUSdKFKUViv' +) | ConvertTo-Json + +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +> **Note:** Cancelling a reservation does not hard-delete it. The reservation remains in the system with a status of `cancelledByUser` and is visible when querying with `settled=true`. Non-admin users can only cancel reservations they created. + Take a look at our **Azure Landing Zone integration** example found under the `deploy` directory in the repository for a real work example of how to automate vNET creation by means of Bicep and leveraging the Azure IPAM API. + +## External Networks + +External Networks allow you to track IP address space that lives outside of Azure (such as on-premises datacenters, co-location facilities, or other cloud providers) directly within Azure IPAM. All External Network operations follow the existing Space/Block hierarchy and are restricted to IPAM administrators. + +For more information on what External Networks are, how they fit into the IPAM hierarchy, and how to manage them via the UI, please see the [External Networks](/how-to/README.md#external-networks) section of the How-To documentation. + +The API base path for External Networks is: + +```text +/api/spaces/{space}/blocks/{block}/externals +``` + +### External Network Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/blocks/{block}/externals` | List all External Networks in a Block | +| `POST` | `/spaces/{space}/blocks/{block}/externals` | Create a new External Network | +| `GET` | `/spaces/{space}/blocks/{block}/externals/{external}` | Get details of a specific External Network | +| `PATCH` | `/spaces/{space}/blocks/{block}/externals/{external}` | Update an External Network (JSON Patch) | +| `DELETE` | `/spaces/{space}/blocks/{block}/externals/{external}` | Delete an External Network | + +### External Subnet Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets` | List all Subnets in an External Network | +| `POST` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets` | Create a new Subnet | +| `GET` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}` | Get details of a specific Subnet | +| `PATCH` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}` | Update a Subnet (JSON Patch) | +| `DELETE` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}` | Delete a Subnet | + +### External Endpoint Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints` | List all Endpoints in a Subnet | +| `POST` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints` | Create a new Endpoint | +| `PUT` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints` | Replace all Endpoints in a Subnet | +| `DELETE` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints` | Delete one or more Endpoints | +| `GET` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}` | Get a specific Endpoint | +| `PATCH` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}` | Update an Endpoint (JSON Patch) | +| `DELETE` | `/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}` | Delete a specific Endpoint | + +### Example API Calls + +The following examples demonstrate common External Network operations using Azure PowerShell. As with the CIDR Reservation examples above, you'll need to obtain an Azure AD token and set up your common variables first. + +```powershell +$engineClientId = '' +$appName = 'ipamdev' +$space = 'MySpace' +$block = 'MyBlock' + +$accessToken = (Get-AzAccessToken -ResourceUrl api://$engineClientId).Token + +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} +``` + +#### Create an External Network + +Here we'll create an External Network with a specific CIDR range. We'll be performing a POST to the following request URL: + +```text +https://ipamdev.azurewebsites.net/api/spaces/MySpace/blocks/MyBlock/externals +``` + +```powershell +$body = @{ + name = 'OnPrem-DC1' + desc = 'On-premises datacenter 1 network' + cidr = '10.0.100.0/24' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +If you don't have a specific CIDR in mind, you can request a network by size and IPAM will allocate the next available range within the Block: + +```powershell +$body = @{ + name = 'OnPrem-DC2' + desc = 'On-premises datacenter 2 network' + size = 24 +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Create an External Subnet + +Once you have an External Network, you can add Subnets to it. The CIDR for the Subnet must fall within the parent External Network's CIDR range. + +```powershell +$external = 'OnPrem-DC1' + +$body = @{ + name = 'ServerSubnet' + desc = 'Server VLAN in DC1' + cidr = '10.0.100.0/26' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external/subnets" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Create an External Endpoint + +With a Subnet in place, you can add individual Endpoints. You can provide a specific IP address, or pass `$null` to have IPAM automatically assign the next available IP within the Subnet. + +```powershell +$subnet = 'ServerSubnet' + +# Create an endpoint with a specific IP +$body = @{ + name = 'db-server-01' + desc = 'Primary database server' + ip = '10.0.100.5' +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external/subnets/$subnet/endpoints" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body + +# Create an endpoint with an auto-assigned IP +$body = @{ + name = 'app-server-01' + desc = 'Application server' + ip = $null +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external/subnets/$subnet/endpoints" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Update an External Network + +You can update External Network properties using a JSON Patch. The same approach works for updating External Subnets and Endpoints by adjusting the request URL accordingly. + +```powershell +$external = 'OnPrem-DC1' + +$body = @( + @{ + op = 'replace' + path = '/desc' + value = 'Updated description for DC1' + } +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Patch' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Bulk Replace Endpoints + +You can replace the entire endpoint list for a Subnet in a single operation using the `PUT` method. This is particularly useful for automation scenarios where an external system produces a complete inventory. + +```powershell +$body = @( + @{ + name = 'db-server-01' + desc = 'Primary database server' + ip = '10.0.100.5' + }, + @{ + name = 'db-server-02' + desc = 'Secondary database server' + ip = '10.0.100.6' + }, + @{ + name = 'app-server-01' + desc = 'Application server' + ip = $null + } +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Put' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external/subnets/$subnet/endpoints" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Delete Endpoints + +You can remove one or more Endpoints from a Subnet by passing an array of endpoint names. + +```powershell +$body = @( + 'db-server-01', + 'app-server-01' +) | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external/subnets/$subnet/endpoints" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body +``` + +#### Delete an External Network + +Deleting an External Network will fail if it contains Subnets unless you pass the `force` query parameter. + +```powershell +# Delete (will fail if subnets exist) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers + +# Force delete (removes the network even if subnets exist) +Invoke-RestMethod ` + -Method 'Delete' ` + -Uri "https://$appName.azurewebsites.net/api/spaces/$space/blocks/$block/externals/$external`?force=true" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers +``` diff --git a/docs/automation/README.md b/docs/automation/README.md new file mode 100644 index 00000000..dfd6330a --- /dev/null +++ b/docs/automation/README.md @@ -0,0 +1,132 @@ +# Automation + +## Overview + +Azure IPAM exposes a full set of capabilities via its REST API, making it well suited for integration into automated workflows. The three primary areas that benefit from automation are: + +- **Reservations** — Claim address space from a Block before deploying infrastructure, then let Azure IPAM automatically settle the reservation when the tagged resource appears. +- **Virtual Network Associations** — Declaratively manage which Azure virtual networks (and virtual hubs) are tracked against each Block. +- **External Networks** — Keep Azure IPAM up to date with IP address space that lives outside of Azure (on-premises, co-location, other clouds). + +This section covers common automation patterns and integration strategies for each of these areas. For details on how to authenticate and call the API, see the [API](/api/README.md) documentation. For general information on these concepts and how to manage them through the UI, see the [How-To](/how-to/README.md) documentation. + +## Reservation Automation + +[Reservations](/how-to/README.md#reservations) are the primary mechanism for integrating Azure IPAM into your Infrastructure as Code (IaC) and deployment pipelines. They allow you to claim address space from a Block, deploy your infrastructure using the reserved CIDR, and have Azure IPAM automatically track the result — all without manual intervention. + +For the full set of reservation endpoints and example API calls, see the [Reservations](/api/README.md#reservations) section of the API documentation. + +### Reserve → Deploy → Tag Workflow + +The core automation pattern for reservations is a three-step workflow: + +1. **Reserve** — Call the reservation API to claim a CIDR of the desired size (or a specific CIDR). IPAM returns the reserved range and a tag (`X-IPAM-RES-ID`). +2. **Deploy** — Create your Azure virtual network using the reserved CIDR as its address prefix, and apply the reservation tag to the resource. +3. **Settle** — Azure IPAM's background task automatically detects the tagged virtual network and marks the reservation as fulfilled. No additional API call is needed. + +This pattern ensures that CIDR allocation, infrastructure deployment, and IPAM tracking all happen in a single automated flow with no manual steps. + +### IaC Examples + +The repository includes working examples of this pattern for both Bicep and Terraform under the [`examples/`](https://github.com/Azure/ipam/tree/main/examples) directory: + +- **Bicep** (`examples/azure-eslz/`) — Uses a Bicep deployment script to call the reservation API via a managed identity, then passes the reserved CIDR and reservation ID to a VNet module that applies the tag. +- **Terraform** (`examples/ipam-terraform/`) — Uses the community [Azure IPAM Terraform provider](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs) to create a reservation as a managed Terraform resource, then creates the VNet with the reserved CIDR and applies the settlement tag. +- **Standalone Scripts** (`examples/scripts/`) — PowerShell and Bash scripts that demonstrate how to call the Azure IPAM API directly. These cover token acquisition and reservation creation, and can serve as starting points for integrating IPAM into custom pipelines, ad-hoc workflows, or tooling that doesn't use Bicep or Terraform. + +These examples can be adapted to fit your own landing zone or spoke deployment patterns. + +> **Terraform Provider:** The [Azure IPAM Terraform provider](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs) (`xtratuscloud/azureipam`) is a community-maintained provider that wraps the Azure IPAM REST API. It supports reservations, spaces, blocks, virtual network associations, and external networks as native Terraform resources and data sources — making it a good fit for teams that manage their infrastructure entirely through Terraform. + +### Multi-Block Reservations + +When you don't need the reservation to come from a specific Block, you can provide a list of Block names and let IPAM evaluate them in order, creating the reservation in the first Block with available space. This is useful for overflow scenarios or environments with tiered address pools. See the [Create a Reservation from Multiple Blocks](/api/README.md#create-a-reservation-from-multiple-blocks) example in the API documentation. + +### Self-Service Pipelines + +Reservations lend themselves well to self-service workflows where application teams can request address space without needing IPAM admin access: + +1. A team triggers a pipeline (e.g., via a pull request or manual dispatch) +2. The pipeline calls the reservation API to claim a CIDR from a pre-approved Block +3. The reserved CIDR is used to deploy the VNet (with the reservation tag applied) +4. IPAM automatically settles the reservation once the tagged VNet is detected + +Because non-admin users can create (and cancel) their own reservations, this pattern works without granting broad IPAM admin permissions. + +### Reservation Lifecycle Management + +For long-running automation, consider monitoring reservation status to catch reservations that remain in a `wait` state longer than expected. A scheduled job can list active reservations (via `GET`) and alert or clean up stale entries. Reservations can be cancelled via `DELETE` if the corresponding deployment was abandoned. See the [Reservations](/api/README.md#reservations) section of the API documentation for the full set of query and delete operations. + +## Virtual Network Association Automation + +[Virtual Network Associations](/how-to/README.md#virtual-network-associations) map Azure virtual networks and virtual hubs to Blocks, which drives utilization tracking and overlap prevention. All association management endpoints are restricted to Azure IPAM administrators. + +For the full set of association endpoints and example API calls, see the [Virtual Network Associations](/api/README.md#virtual-network-associations) section of the API documentation. + +### Declarative Association Management + +The bulk replace (`PUT`) endpoint for associations accepts the complete list of virtual network resource IDs that should be associated with a Block. Any networks not in the list are disassociated, and any new ones are added. This makes it a natural fit for declarative, GitOps-style workflows: + +1. Maintain a configuration file (JSON, YAML, etc.) that defines which virtual networks belong to each Block +2. On each merge to your main branch, a pipeline reads the file and calls `PUT /networks` for each Block +3. IPAM's association state always matches your declared intent + +### Post-Deployment Association + +If your VNet deployments don't use the reservation workflow (for example, if address space is allocated outside of IPAM), you can add an association step to the end of your deployment pipeline: + +1. Deploy the virtual network +2. Call `POST /networks` to associate the new VNet with the appropriate Block + +This ensures that every deployed VNet is tracked in IPAM for utilization and overlap reporting, even if it wasn't provisioned through the reservation flow. + +### Drift Detection and Reconciliation + +Over time, associations can drift if virtual networks are created, deleted, or moved outside of your automated pipelines. A scheduled reconciliation job can detect and correct this: + +1. **Query** the available networks for a Block (`GET /available`) to find eligible VNets that are not yet associated +2. **Query** the current associations (`GET /networks`) to find stale entries pointing to deleted resources +3. **Reconcile** by adding missing associations and removing stale ones + +This is particularly valuable in large environments where multiple teams deploy infrastructure independently. + +## External Network Automation + +[External Networks](/how-to/README.md#external-networks) represent IP address space that lives outside of Azure — on-premises datacenters, co-location facilities, other cloud providers, etc. Because this information is often managed by separate systems and processes, keeping Azure IPAM up to date manually can be tedious and error-prone. + +For the full set of external network endpoints and example API calls, see the [External Networks](/api/README.md#external-networks) section of the API documentation. + +### CMDB / IPAM Synchronization + +If your organization maintains a Configuration Management Database (CMDB) or another source of truth for on-premises network inventory, you can build an automated synchronization process that periodically exports data from that system and pushes it into Azure IPAM. This ensures that your Azure IPAM instance always reflects the current state of your non-Azure networks. + +A typical sync flow: + +1. **Export** the current on-premises network and host inventory from your CMDB +2. **Map** the exported data to the External Network / Subnet / Endpoint hierarchy +3. **Reconcile** the data against what currently exists in Azure IPAM (using `GET` calls) +4. **Create, Update, or Delete** External Networks, Subnets, and Endpoints as needed to bring IPAM in sync + +The **bulk replace** (`PUT`) endpoint for Subnet Endpoints is particularly useful here, as it allows you to replace the entire endpoint list in a single call rather than managing individual additions and deletions. + +### Network Scanner Integration + +Network scanning tools (such as Nmap, or enterprise solutions like Infoblox or SolarWinds) can be integrated to automatically populate External Subnet Endpoints. After a scan completes, the results can be parsed and pushed to Azure IPAM to maintain an up-to-date view of what hosts are active on each subnet. + +A basic integration flow: + +1. **Run a network scan** against your on-premises or external subnets +2. **Parse the results** to extract host names, descriptions, and IP addresses +3. **Push the results** to Azure IPAM using the bulk replace (`PUT`) endpoint for the corresponding External Subnet + +This can be scheduled to run on a regular cadence (e.g., nightly or weekly) to keep your endpoint inventory current. + +### IaC and CI/CD Integration + +External Networks can be provisioned as part of your IaC pipelines alongside your Azure resources. For example, when standing up a new site or datacenter, your deployment pipeline could: + +1. Create the corresponding External Network and Subnets in Azure IPAM +2. Deploy the Azure-side networking (VPN Gateways, ExpressRoute circuits, etc.) +3. Ensure the full address plan is captured in a single source of truth + +For teams that manage network configurations through version-controlled repositories, a CI/CD pipeline can be configured to automatically update Azure IPAM whenever network definitions change — for example, by maintaining a JSON or YAML file that defines your external network topology and having a pipeline step reconcile it against the Azure IPAM API on each merge. diff --git a/docs/contributing/README.md b/docs/contributing/README.md index a0b400f1..a70ec568 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -12,9 +12,17 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +## How to Contribute + +1. Fork the repository and clone your fork locally +2. Create a feature or fix branch from `main` (e.g. `feature/my-change` or `fix/issue-123`) +3. Make your changes and test them locally using the development environment described below +4. Commit your changes and push the branch to your fork +5. Open a Pull Request against the `main` branch of the upstream repository + ## Running an Azure IPAM Development Environment with Docker Compose -We have included a Docker Compose file in the root directory of the project (`docker-compose.yml`), to quickly build a fully functional Azure IPAM development environment. The Docker Compose file is also dependant on an `env` file to correctly pass all of the required environment variables into the containers. You can use the `env.example` file, also found at the root directory of the project, as a template to create your own `env` file. +We have included a Docker Compose file in the root directory of the project (`docker-compose.yml`), to quickly build a fully functional Azure IPAM development environment. The Docker Compose file is also dependent on an `.env` file to correctly pass all of the required environment variables into the containers. You can use the `.env.example` file, also found at the root directory of the project, as a template to create your own `.env` file. To start a development environment of the Azure IPAM solution via Docker Compose, run the following commands from the root directory of the project: @@ -31,15 +39,19 @@ docker compose rm -s -v -f ## Building Production Containers Images and Pushing them to DockerHub -We use Dockerfiles to build the containers for the Azure IPAM solution and have two located in the root directory of the project. One is designed for use when running inside a solution such as Azure App Services (as well as other containerized environments) and another specifically designed for running inside Azure Functions. If you choose, you can build these containers yourself and host them in DockerHub. +We use Dockerfiles to build the containers for the Azure IPAM solution and have three located in the root directory of the project. One is designed for use when running inside a solution such as Azure App Services (as well as other containerized environments), one specifically designed for running inside Azure Functions, and one for RHEL-based deployments. If you choose, you can build these containers yourself and host them in DockerHub. To do so, run the following Docker commands from the root directory of the project: ```shell -# App Services Container +# App Services Container (Debian) docker build --rm --no-cache -t /ipam:latest -f ./Dockerfile.deb . docker push /ipam:latest +# App Services Container (RHEL) +docker build --rm --no-cache -t /ipam:latest -f ./Dockerfile.rhel . +docker push /ipam:latest + # Function Container docker build --rm --no-cache -t /ipamfunc:latest -f ./Dockerfile.func . docker push /ipamfunc:latest @@ -52,7 +64,7 @@ In addition to the DockerHub option (above), alternatively you may choose to lev Before running the update commands, you'll need to authenticate to the Azure CLI ```shell -# Authenicate to Azure CLI +# Authenticate to Azure CLI az login # Set Target Azure Subscription @@ -62,9 +74,12 @@ az account set --subscription "" Next, use the following commands to update the Azure IPAM containers within your private Azure Container Registry ```shell -# App Services Container +# App Services Container (Debian) az acr build -r -t ipam:latest -f ./Dockerfile.deb . +# App Services Container (RHEL) +az acr build -r -t ipam:latest -f ./Dockerfile.rhel . + # Function Container az acr build -r -t ipamfunc:latest -f ./Dockerfile.func . ``` diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 72591710..cf27fd65 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -11,24 +11,24 @@ To successfully deploy the solution, the following prerequisites must be met: - [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner) - [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator) - [Custom Role](https://learn.microsoft.com/azure/role-based-access-control/custom-roles) with *allow* permissions of `Microsoft.Authorization/roleAssignments/write` - - [Global Administrator](https://learn.microsoft.com/azure/active-directory/roles/permissions-reference#global-administrator) (needed to grant admin consent for the App Registration API permissions) + - [Global Administrator](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator) (needed to grant admin consent for the App Registration API permissions) - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed - Required to clone the Azure IPAM GitHub repository - [PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) version 7.2.0 or later installed -- [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 8.0.0 or later installed (11.4.0 or later recommended) +- [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 11.0.0 or later installed - [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/powershell/microsoftgraph/installation) version 2.0.0 or later installed - - Required for *Full* or *Identities Only* deployments to grant [Admin Consent](https://learn.microsoft.com/azure/active-directory/manage-apps/grant-admin-consent) to the App Registrations + - Required for *Full* or *Identities Only* deployments to grant [Admin Consent](https://learn.microsoft.com/entra/identity/enterprise-apps/grant-admin-consent) to the App Registrations - [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) version 0.21.1 or later installed - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) version 2.35.0 or later installed (optional) - Required only if you are building your own container image and pushing it to a private Azure Container Registry (Private ACR) - [Docker (Linux)](https://docs.docker.com/engine/install/) / [Docker Desktop (Windows)](https://docs.docker.com/desktop/install/windows-install/) installed (optional) - Required only if you are building your own container image and running it locally for development/testing purposes -> **NOTE:** An alternate [Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview) can be specific, but is **highly discouraged** as it will limit the visibility of the Azure IPAM platform. This option should only be used for testing or proof-of-concept deployments. +> **NOTE:** An alternate [Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview) can be specified, but is **highly discouraged** as it will limit the visibility of the Azure IPAM platform. This option should only be used for testing or proof-of-concept deployments. ## Deployment Overview -The Azure IPAM solution is deployed via a PowerShell deployment script, `deploy.ps1`, found in the `deploy` directory of the project. The infrastructure stack is defined via Azure Bicep files. The deployment can be performed via your local machine or from the development container found in the project. You have the following options for deployment: +The Azure IPAM solution is deployed via a PowerShell deployment script, `deploy.ps1`, found in the `deploy` directory of the project. The infrastructure stack is defined via Azure Bicep files. The deployment can be performed via your local machine or from the development container found in the project. You have the following options for deployment: - Two-part deployment *(Azure Identities and Permissions Only)* - Part 1: Azure Identities only @@ -45,7 +45,7 @@ The Azure IPAM solution is deployed via a PowerShell deployment script, `deploy. - Azure infrastructure components are deployed - Azure App Service is pointed to public or private Azure Container Registry -The two-part deployment option is provided in the event that a single team within your organization doesn't have the necessary permissions to deploy both the Azure identities within Entra ID, and the Azure infrastructure stack. If a single group does have all of the the necessary permissions in Entra ID and on the Azure infrastructure side, then you have the option to deploy the complete solution all at once. +The two-part deployment option is provided in the event that a single team within your organization doesn't have the necessary permissions to deploy both the Azure identities within Entra ID, and the Azure infrastructure stack. If a single group does have all of the necessary permissions in Entra ID and on the Azure infrastructure side, then you have the option to deploy the complete solution all at once. ## Authenticate to Azure PowerShell @@ -140,8 +140,10 @@ You have the ability to pass optional flags to the deployment script: | `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | | `-Function` | Deploys the engine container only to an Azure Function | | `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| `-Native` | Deploys the application natively via zip-deploy instead of using containers | +| `-ContainerType ` | Specifies the container base image: `Debian` (default) or `RHEL` **4** | | `-DisableUI` | Solution will be deployed without a UI, no UI identities will be created | -| `-MgmtGroupId` | Specifies an alternate Management Group instead of the Root Management Group **4** | +| `-MgmtGroupId` | Specifies an alternate Management Group instead of the Root Management Group **5** | > **NOTE 1:** The required values will vary based on the deployment type. @@ -149,7 +151,9 @@ You have the ability to pass optional flags to the deployment script: > **NOTE 3:** Maximum of seven (7) characters. This is because the prefix is used to generate names for several different Azure resource types with varying maximum lengths. -> **NOTE 4:** It is **highly discouraged** to use a [Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview) other than the [Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) as it will limit the visibility of the Azure IPAM platform. This option should only be used for testing or proof-of-concept deployments. +> **NOTE 4:** The `RHEL` option is intended for environments that require Red Hat Enterprise Linux, such as government, DoD, or other controlled environments. In most cases the default `Debian` image is sufficient. + +> **NOTE 5:** It is **highly discouraged** to use a [Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview) other than the [Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) as it will limit the visibility of the Azure IPAM platform. This option should only be used for testing or proof-of-concept deployments. **Customize the name of the App Registrations:** @@ -252,7 +256,7 @@ $ResourceNames = @{ ./deploy.ps1 ` -Location "westus3" ` - -ResourceNames $ResourceNames + -ResourceNames $ResourceNames ` -Function ``` @@ -314,6 +318,8 @@ You have the ability to pass optional flags to the deployment script: | `-ResourceNames @{​​​​​​ = '​'; ​ = '​'}` | Overrides default resource names with custom names **1,2** | | `-NamePrefix ` | Replaces the default resource prefix of "ipam" with an alternative prefix **3** | | `-PrivateACR` | Deploys a private Azure Container Registry and builds the IPAM containers | +| `-Native` | Deploys the application natively via zip-deploy instead of using containers | +| `-ContainerType ` | Specifies the container base image: `Debian` (default) or `RHEL` **4** | | `-Function` | Deploys the engine container only to an Azure Function | > **NOTE 1:** The required values will vary based on the deployment type. @@ -322,6 +328,8 @@ You have the ability to pass optional flags to the deployment script: > **NOTE 3:** Maximum of seven (7) characters. This is because the prefix is used to generate names for several different Azure resource types with varying maximum lengths. +> **NOTE 4:** The `RHEL` option is intended for environments that require Red Hat Enterprise Linux, such as government, DoD, or other controlled environments. In most cases the default `Debian` image is sufficient. + **Change the name prefix for the Azure resources:** ```powershell @@ -406,8 +414,8 @@ $ResourceNames = @{ ```powershell $ResourceNames = @{ - functionName = 'myappservice01' - appServicePlanName = 'myappserviceplan01' + functionName = 'myfunction01' + functionPlanName = 'myfunctionplan01' cosmosAccountName = 'mycosmosaccount01' cosmosContainerName = 'mycontainer01' cosmosDatabaseName = 'mydatabase01' diff --git a/docs/how-to/README.md b/docs/how-to/README.md index 4ebe6cff..ee69ec35 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -1,101 +1,679 @@ -# How to Use IPAM +# How to Use Azure IPAM ## Authentication and Authorization -![IPAM Homepage](./images/home_page.png) +![Azure IPAM Homepage](./images/ipam_home_page.png) -IPAM leverages the [Microsoft Authentication Library (MSAL)](https://docs.microsoft.com/azure/active-directory/develop/msal-overview) in order to authenticate users. It uses your existing Azure AD credentials to authenticate you and leverages your existing Azure RBAC permissions to authorize what information is visible from within the IPAM tool. +Azure IPAM leverages the [Microsoft Authentication Library (MSAL)](https://docs.microsoft.com/azure/active-directory/develop/msal-overview) to authenticate users with your existing Microsoft Entra ID credentials. Authorization is determined by whether the signed-in user is an **IPAM Administrator**: -IPAM has the concept of an **IPAM Administrator**. While using the IPAM tool as an administrator, you are viewing Azure resources through the permissions of the Engine Service Principal which, by default, has [Reader](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#reader) at the [Tenant Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) level (unless specified otherwise at deployment time). Upon initial deployment, no IPAM administrators are set which has the effect of **all** users having administrative rights. You can define who within your Azure AD Tenant should be designated as an IPAM administrator via the **Admin** section of the menu blade. +- **Administrators** view Azure resources through the Engine Service Principal, which by default has [Reader](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#reader) at the [Tenant Root Management Group](https://learn.microsoft.com/azure/governance/management-groups/overview#root-management-group-for-each-directory) (unless overridden during deployment). Administrators can also create and update [Spaces](#spaces) and [Blocks](#blocks), manage [subscription exclusions](#subscription-exclusioninclusion), and control who else is an admin. +- **Non-administrators** see only the resources they already have access to in the Azure Portal, based on their own Azure RBAC permissions. Administrative functions such as the **Configure** and **Admin** menu sections are not available to them. -![IPAM Admins](./images/ipam_admin_admins.png) +> **Note:** Upon initial deployment, no administrators are defined. When the admin list is empty, **all** users are treated as administrators. This grants full access so you can complete initial setup, but you should designate at least one administrator promptly to restrict administrative functions. -IPAM administrators have the ability to configure create/update [Spaces](#spaces) and [Blocks](#blocks) via the **Configure** section of the menu blade (more on that below). Once at least one IPAM administrator is set, non-admin users will only see resources in IPAM they already have access to from the Azure Portal, and the administrative functions of the IPAM tool will no longer be available to them. +### Managing Administrators -![IPAM Admins Config](./images/ipam_administrators_config.png) +To manage Azure IPAM administrators, expand the **Admin** section of the menu blade and select **Admins**. + +![Azure IPAM Admins](./images/ipam_admin_admins.png) + +The page displays a table of current administrators along with a search bar at the top. Azure IPAM supports two types of administrators: + +- **Users** — Microsoft Entra ID user accounts, searchable by display name. +- **Principals** — Microsoft Entra ID Service Principals (application identities), searchable by display name. + +Click the toggle button to the left of the search bar to switch between **User** and **Principal** search. + +![Azure IPAM Admin User Search](./images/ipam_admin_user_search.png) + +![Azure IPAM Admin Principal Search](./images/ipam_admin_principal_search.png) + +Type a name into the search bar to find matching entries via Microsoft Graph, then select one to add it to the admin list. + +![Azure IPAM Admin Search Results](./images/ipam_admin_search_results.png) + +To remove an administrator, click the **delete** icon on its row. The **save** icon in the upper-right corner appears only when you have unsaved changes. Click it to commit the updated admin list. + +![Azure IPAM Admins Config](./images/ipam_admin_user_delete.png) ## Subscription Exclusion/Inclusion -As an IPAM administrator, you have the ability to include/exclude subscriptions from the IPAM view. To do so, expand the **Admin** section of the menu blade and select **Subscriptions**. +As an IPAM administrator, you can exclude specific Azure subscriptions from IPAM. Excluded subscriptions are filtered out of all Azure Resource Graph queries, meaning their virtual networks, subnets, virtual hubs, and endpoints will not appear anywhere in the Discover or Configure views. This is useful for omitting subscriptions that contain irrelevant networks (such as sandbox or lab environments) so they don't clutter your IP address management view or skew utilization calculations. + +To manage exclusions, expand the **Admin** section of the menu blade and select **Subscriptions**. ![IPAM Admin Subscriptions](./images/ipam_admin_subscriptions.png) -From this screen, you can select Subscriptions which are to be **excluded** from IPAM by clicking on them. Once selected for exclusion, the subscription will be highlighted in **red**. Don't forget to click **save** in the upper-right once complete. +The subscription list displays all subscriptions visible to IPAM, along with their type and management group. Click a subscription row to toggle it between included and excluded. Excluded subscriptions are highlighted in **red**. Click the same row again to re-include it. + +The **save** icon in the upper-right corner appears only when you have unsaved changes. Click it to commit your selections. You can also use the action menu to toggle between viewing all subscriptions or only the currently excluded ones. ![IPAM Admin Subscriptions Config](./images/ipam_admin_subscriptions_config.png) +> **Note:** Excluding a subscription does not affect any existing Space or Block configurations. If a virtual network from an excluded subscription is already associated with a Block, the association record is preserved but the network will appear as [stale](#managing-associations-via-the-ui) since it can no longer be resolved through Azure Resource Graph. + +## Navigating the Discover Section + +The **Discover** section of the Azure IPAM menu blade provides a read-only view of your IP address space and Azure network resources. It is organized into six tabs: **Spaces**, **Blocks**, **vNets**, **Subnets**, **vHubs**, and **Endpoints**. Each tab presents a table with sortable and filterable columns. + +Several interaction patterns are shared across all Discover tabs: + +- **Utilization bars** — Where applicable, a color-coded utilization bar shows how much of the address space is consumed. The bar displays **green** at 70% or below, **yellow** between 71–89%, and **red** at 90% or above. +- **Details panel** — Click the expand chevron (❭) on any row to slide open a details panel on the right side. The panel shows additional information about the selected resource, a utilization gauge (where applicable), and a **VIEW IN PORTAL** button that opens the resource directly in the Azure Portal. +- **Drill-down navigation** — Some columns display an arrow icon (⤷) next to the value. Clicking the arrow navigates to a related tab, pre-filtered to show only child or associated resources. For example, clicking the arrow on a Space name takes you to the Blocks tab filtered to that Space. +- **Hidden columns** — Some columns (such as Subscription ID) are hidden by default to keep the grid readable. You can reveal them through the column menu. + +> **Tip:** The Discover section reflects what you have access to. Non-admin users see resources based on their Azure RBAC permissions, while IPAM administrators see resources across the entire tenant. + ## Spaces -A **Space** represents a logical grouping of *unique* IP address space. **Spaces** can contain both contiguous and non-contiguous IP address CIDR blocks. A **Space** cannot contain any overlapping CIDR blocks. As an IPAM user, you can get to the **Spaces** tab via the **Discover** section of the menu blade. From the **Spaces** tab, you can see utilization metrics for each **Space**. +A **Space** represents a logical grouping of *unique* IP address space. Spaces can contain both contiguous and non-contiguous IP address CIDR blocks, but cannot contain overlapping blocks. They are the top-level organizational unit in Azure IPAM's hierarchy. + +### Viewing Spaces + +Navigate to **Discover → Spaces** to see all Spaces. The grid shows each Space's **Name** (with a drill-down arrow to its Blocks), **Description**, **Utilization** bar, total **Size**, and **Used** address count. ![IPAM Spaces](./images/discover_spaces.png) -As an IPAM Administrator, you can add **Spaces** via the **Configure** section of the menu blade. Clicking on the 3 ellipses will bring up a menu of **Space** operations. Select **Add Space**. +### Managing Spaces -![IPAM Add Space](./images/add_space.png) +Spaces are managed from the **Configure → Basics** page. The upper portion of the page shows the Space data grid. -Give the new **Space** a name and a description, then click **Create** to create a new **Space**. +> **Note:** Space management (creating, editing, and deleting) is an **IPAM Administrator** function. + +**Adding a Space** — Click the action menu (down chevron) and select **Add Space**. Provide a name and description, then click **Create**. + +![IPAM Add Space](./images/add_space.png) ![IPAM Add Space Details](./images/add_space_details.png) +**Editing a Space** — Select a Space in the grid, then choose **Edit Space** from the action menu. You can update the name or description. + +**Deleting a Space** — Select a Space and choose **Delete Space** from the action menu. If the Space contains Blocks, you will need to enable **Force Delete** to confirm the deletion. Force-deleting a Space removes all of its Blocks and their associated data (virtual network associations, reservations, and external networks). + +Space names must be 1–64 characters and can contain alphanumeric characters, underscores, hyphens, and periods. They cannot start or end with a period, underscore, or hyphen. + +### Managing Spaces via the API + +Space operations are also available through the Azure IPAM REST API. For the full list of available endpoints and example calls, please see the [Spaces](/api/README.md#spaces) section of the API documentation. + ## Blocks -A **Block** represents an IP address CIDR range. It can contain vNETs whose address space resides within the defined CIDR range of the **Block**. **Blocks** cannot contain vNETs with overlapping address space. As an IPAM user, you can get to the **Blocks** tab via the **Discover** section of the menu blade. From the **Blocks** tab, you can see utilization metrics for each **Block**. +A **Block** represents an IPv4 CIDR range within a Space. It can contain Azure virtual networks and virtual hubs whose address space falls within the Block's CIDR range, along with CIDR Reservations and External Networks. Blocks within the same Space cannot have overlapping CIDR ranges. + +### Viewing Blocks + +Navigate to **Discover → Blocks** to see all Blocks. The grid shows each Block's **Name** (with a drill-down arrow to its vNets and vHubs), parent **Space**, **CIDR** range, **Utilization** bar, total **Size**, and **Used** address count. ![IPAM Blocks](./images/discover_blocks.png) -As an IPAM Administrator, you can add **Blocks** via the **Configure** section of the menu blade. After selecting which **Space** you want to add a **Block** to, clicking on the 3 ellipses will bring up a menu of **Block** operations. Select **Add Block**. +### Managing Blocks -![IPAM Add Block](./images/add_block.png) +Blocks are managed from the **Configure → Basics** page. Select a Space in the upper table to populate the Block table in the lower half of the page. + +> **Note:** Block management (creating, editing, and deleting) is an **IPAM Administrator** function. -Give the new **Block** a name a valid CIDR range, then click **Create** to create add a new **Block** to the target **Space**. +**Adding a Block** — Click the action menu (down chevron) on the Block grid and select **Add Block**. Provide a name and a valid IPv4 CIDR range, then click **Create**. + +![IPAM Add Block](./images/add_block.png) ![IPAM Add Block Details](./images/add_block_details.png) -## Virtual Network Association +**Editing a Block** — Select a Block and choose **Edit Block** from the action menu. You can update the name or CIDR range. When changing the CIDR, the new range must still contain all currently associated virtual networks, reservations, and external networks. -As an IPAM Administrator, you can associate Azure virtual networks to **Blocks**. To associate a virtual network to a **Block**, select the **Block** you want to associate the virtual network to, then click on the 3 ellipses to bring up a menu of **Block** operations. Select **Virtual Networks**. +**Deleting a Block** — Select a Block and choose **Delete Block** from the action menu. If the Block contains any virtual network associations or active reservations, you will need to enable **Force Delete** to proceed. -![IPAM Associate vNETs](./images/virtual_network_association.png) +Block names follow the same naming rules as Spaces (1–64 characters, alphanumerics, underscores, hyphens, and periods). -Place a checkmark next to the virtual networks you'd like to associate to the target **Block**, or un-check virtual networks you'd like to disassociate from the target **Block**, then click **Apply**. +> **Shortcut:** The Block action menu also provides quick links to **Block Networks** (Associations), **Reservations**, and **External Networks**. Selecting one of these navigates to the corresponding Configure tab with the Space and Block pre-selected. -![IPAM Associate vNETs Details](./images/virtual_network_association_details.png) +### Managing Blocks via the API -## Reservations +Block operations are also available through the Azure IPAM REST API. For the full list of available endpoints and example calls, please see the [Blocks](/api/README.md#blocks) section of the API documentation. -Currently, IP CIDR block reservations are not supported via the UI, but are supported programmatically via the API. Please see the **Example API Calls** section for more information on how to create IP address block reservations. +## Azure Network Resources -## vNETs, Subnets, and Endpoints +As an Azure IPAM user, you can view IP address utilization information and detailed Azure resource data for **vNets**, **Subnets**, **Virtual Hubs (vHubs)**, and **Endpoints** that you have existing Azure RBAC access to. Each resource type has its own tab in the **Discover** section. -As an IPAM user, you can view IP address utilization information and detailed Azure resource related information for **vNETs**, **Subnets**, and **Endpoints** you have existing Azure RBAC access to. +> **Tip:** Every resource tab supports the expand chevron (❭) on each row. Expanding a row slides open a details panel with in-depth information about the selected resource — such as additional properties, a utilization gauge (where applicable), and a **VIEW IN PORTAL** button to jump straight to the resource in the Azure Portal. ### Virtual Networks -For **vNETs**, you can find the name, view the parent **Block** (if assigned), utilization metrics, and the **vNET** address space(s). +The **vNets** tab shows all Azure virtual networks visible to you. The table displays the **Name** (with a drill-down arrow to Subnets), parent **Block** (if associated), **Utilization** bar, total **Size**, **Used** address count, and **Prefixes** (address spaces). Additional columns such as **Resource Group**, **Subscription Name**, and **Subscription ID** are available but hidden by default. ![IPAM vNETs](./images/discover_vnets.png) -By clicking to expand the **vNET** details, you can find more granular **vNET** information and are presented the option to view the **vNET** resource directly in the Azure Portal by clicking on **VIEW IN PORTAL**. - ![IPAM vNETs Details](./images/discover_vnets_details.png) ### Subnets -For **Subnets**, you can find the name, view the parent **vNET**, utilization metrics, and the **Subnet** address range. +The **Subnets** tab shows all subnets across your visible virtual networks. The grid shows the **Name** (with a drill-down arrow to Endpoints), parent **vNet**, **Utilization** bar, **Size**, **Used** count, and **Prefix**. Additional columns for Resource Group, Subscription Name, and Subscription ID are hidden by default. ![IPAM Subnets](./images/discover_subnets.png) -By clicking to expand the **Subnet** details, you can find more granular **Subnet** information and are presented the option to view the **Subnet** resource directly in the Azure Portal by clicking on **VIEW IN PORTAL**. - ![IPAM Subnets Details](./images/discover_subnets_details.png) +### Virtual Hubs + +The **vHubs** tab shows Azure Virtual WAN hubs that have been associated with a Block. Virtual Hubs are a component of [Azure Virtual WAN](https://learn.microsoft.com/azure/virtual-wan/virtual-wan-about) and serve as the central networking point for branch, site-to-site, and point-to-site connectivity. + +The grid displays the **Name** (with a drill-down arrow to Endpoints), parent **Virtual WAN** name, parent **Block** (if associated), **Prefixes**, and **Resource Group**. Additional columns for Subscription Name and Subscription ID are hidden by default. + + +![IPAM vHubs](./images/discover_vhubs.png) + +![IPAM vHubs Details](./images/discover_vhubs_details.png) + +> **Note:** Unlike vNets and Subnets, vHubs do not display a utilization bar in the Discover grid. Azure manages the internal address allocation within a Virtual Hub (for gateway subnets, routing infrastructure, firewall, etc.) and does not expose IP address utilization data through its APIs. Because of this, IPAM can track a hub's address prefix and its Block association but cannot report how much of that space is consumed. Virtual hubs are associated with Blocks via the same [Virtual Network Associations](#virtual-network-associations) mechanism as vNets. + ### Endpoints -For **Endpoints**, you can find the name, view the parent **vNET** and **Subnet**, Resource Group, and the private IP of the **Endpoint** +The **Endpoints** tab shows individual network endpoints (NICs and private endpoints) across your visible virtual networks. The grid shows the **Name**, parent **vNet** and **Subnet**, **Resource Group**, and **Private IP**. Additional columns for Subscription Name and Subscription ID are hidden by default. ![IPAM Endpoints](./images/discover_endpoints.png) -By clicking to expand the **Endpoint** details, you can find more granular **Endpoint** information (which varies based on the endpoint type) and are presented the option to view the **Endpoint** resource directly in the Azure Portal by clicking on **VIEW IN PORTAL**. +> **Note:** Endpoints whose parent or target resource no longer exists are considered *orphaned* and are flagged with an informational indicator next to their name. For example, a private endpoint is orphaned when the resource it was created to connect to has been deleted, and a network interface is orphaned when it is no longer associated with a virtual machine or other compute resource. ![IPAM Endpoints Details](./images/discover_endpoints_details.png) + +## Virtual Network Associations + +Virtual Network Associations are the mechanism by which Azure virtual networks (vNETs) and virtual hubs (vHUBs) are mapped to **Blocks** in Azure IPAM. Associating a virtual network with a Block tells Azure IPAM that the network's address space is allocated from that Block's CIDR range. This is the foundation of how Azure IPAM tracks IP address utilization — without associations, Azure IPAM has no way of knowing which networks belong to which Blocks. + +Associations serve several important purposes: + +- **Utilization tracking** — Associated virtual networks are counted toward a Block's used address space, giving you an accurate picture of how much of the Block is consumed +- **Overlap prevention** — When creating new CIDR Reservations, External Networks, or additional associations, Azure IPAM checks against all currently associated virtual networks to prevent address collisions +- **Network visibility** — Once associated, a virtual network's subnets and endpoints become visible under their parent Block in the **Discover** section of Azure IPAM + +> **Note:** Virtual Network Association management is an **IPAM Administrator** function. Only users designated as Azure IPAM admins can create or modify associations. However, all users with access to Azure IPAM can view the current associations for a Block in a read-only capacity. + +### How Associations Work + +Each **Block** maintains a list of associated virtual networks. An association is simply a mapping between an Azure virtual network resource ID and the Block. When Azure IPAM calculates utilization for a Block, it sums the address prefixes of all associated virtual networks that fall within the Block's CIDR range. + +The association data is stored within the Block itself, alongside CIDR Reservations and External Networks: + +```text +Space +└── Block (e.g. 10.0.0.0/16) + ├── Virtual Network Associations ← You are here + ├── CIDR Reservations + └── External Networks +``` + +Azure IPAM's background reconciliation process runs every minute and checks each associated virtual network against Azure to confirm it still exists and still has address space within the Block's CIDR range. If the network has been deleted or its address space no longer falls within the Block, it is marked as stale. Stale associations are highlighted in the UI so administrators can clean them up. + +### Availability and Eligibility Rules + +Not every virtual network in your Azure environment is eligible for association with a given Block. When you open the associations page for a Block, Azure IPAM queries for all virtual networks and virtual hubs across your subscriptions that meet the following requirements: + +1. **CIDR containment** — The virtual network must have at least one address prefix that falls within the Block's CIDR range +2. **No CIDR overlap with Reservations** — The virtual network's address prefixes must not overlap with any unsettled (active) CIDR Reservations in the Block +3. **No CIDR overlap with External Networks** — The virtual network's address prefixes must not overlap with any External Network CIDRs in the Block + +Virtual networks that are already associated with the Block are included in the available list (they appear as pre-selected in the table). Overlap with existing associations is validated when you save your changes. + +Spaces are independent logical boundaries — a virtual network associated with a Block in one Space can still appear as available for Blocks in other Spaces. Within the same Space, a virtual network with multiple address prefixes can be associated with different Blocks, since Blocks in a Space cannot have overlapping CIDRs and each prefix is evaluated independently. + +> **Tip:** If a virtual network or virtual hub you expect to see is missing from the available list, check whether its address space falls within the Block's CIDR range and whether it overlaps with an existing Reservation or External Network in the target Block. + +### Managing Associations via the UI + +Virtual Network Associations are managed from the **Configure** section of the Azure IPAM menu blade. There are two ways to get there: + +**Option 1: Direct navigation** — Expand the **Configure** section of the menu blade and select **Associations**. This takes you to the Associations page where you can select a Space and Block. + +**Option 2: From the Block configuration** — Navigate to **Configure → Basics**, select a Space and Block, then open the action menu (⋮) and select **Block Networks**. This takes you to the Associations page with the Space and Block pre-selected. + +![IPAM Associate vNETs](./images/virtual_network_association.png) + +#### The Associations Page + +The Associations page displays a toolbar at the top with selectors for **Space** and **Block**, a read-only **Network** field showing the Block's CIDR, and a selection counter showing how many virtual networks are currently selected out of the total available. + +Below the toolbar is a data grid showing all eligible virtual networks for the selected Block. The grid displays the following columns: + +- **Name** — The name of the virtual network or virtual hub +- **Type** — Whether the network is a **vNET** or a **vHUB** +- **Resource Group** — The Azure resource group containing the network +- **Subscription Name** — The Azure subscription containing the network +- **Subscription ID** — The Azure subscription ID (hidden by default) +- **Prefixes** — The address space(s) assigned to the virtual network + +![IPAM Associate vNETs Details](./images/virtual_network_association_details.png) + +> **Note:** Virtual networks that are currently associated with the Block are pre-selected (checked) when the page loads. + +#### Associating Virtual Networks + +To associate virtual networks with a Block, place a checkmark next to each virtual network you'd like to associate. The selection counter in the toolbar updates in real time as you make changes. Once your selection differs from the current associations, a **Save** button appears near the upper right in the toolbar. + +Click **Save** to apply your changes. On success, you'll see a confirmation notification and the Block's virtual network list has been updated. Note that saving performs a **full replacement** — the Block's entire list of associated networks is replaced with whatever is currently selected in the grid. + +![IPAM Associate vNETs Update](./images/virtual_network_association_update.png) + +#### Disassociating Virtual Networks + +To disassociate a virtual network from a Block, simply un-check it in the grid and click **Save**. Disassociating a virtual network releases its address prefixes from the Block's utilization calculations, making that space available for new allocations. + +#### Stale Associations + +A virtual network association can become stale for two reasons: + +- **Deleted network** — The virtual network or virtual hub has been removed from Azure entirely. In this case, the prefixes column displays `ErrNotFound` because IPAM can no longer retrieve information about the resource. +- **Address space mismatch** — The virtual network still exists in Azure, but its address space has been changed so that it no longer overlaps with the Block's CIDR range. In this case, the prefixes column shows the network's **current address space**, making it easier to understand what changed. + +Azure IPAM's background reconciliation process detects both conditions and marks the affected associations as inactive. Stale associations are displayed at the top of the grid with a **red background** to draw attention. + +![IPAM Associate vNETs Stale](./images/virtual_network_association_stale.png) + +To clean up stale associations, un-check the stale entries and click **Save** to remove them from the Block. + +#### Admin vs. Non-Admin View + +Non-admin users can navigate to the Associations page and view the current associations for any Block. However, the grid is displayed in **read-only mode** — checkboxes are not shown and the Save button is never visible. This allows everyone to see which virtual networks are associated with a Block without being able to modify the associations. + +> **Important:** Administrators see networks across the entire tenant. Non-admin users only see networks in subscriptions they have Azure RBAC read access to, so their available network list may be smaller. + +### Automatic Association via Reservations + +Virtual networks created through the CIDR Reservation workflow are **automatically associated** with their Block — no manual step is needed. Azure IPAM's background reconciliation detects the tagged network, verifies its address space, and creates the association for you. See the [Reservations](#reservations) section for details on how this works. + +### How Associations Affect Utilization + +Azure IPAM calculates utilization at three levels of the hierarchy — Block, virtual network, and subnet — each answering a different question about how your address space is being consumed. + +#### Block Utilization + +Block utilization shows how much of a Block's total CIDR range has been allocated to virtual networks and external networks. Only vNet address prefixes that fall within the Block's CIDR are counted — if a virtual network has multiple address spaces and only one falls within the Block, only that one is included. + +```text +Block Utilization = (Associated vNet Prefixes + External Network CIDRs) / Block CIDR Size +``` + +For example, a Block of `10.0.0.0/16` (65,536 addresses) with two associated virtual networks of `10.0.1.0/24` (256 addresses) and `10.0.2.0/24` (256 addresses) would show a utilization of 512 / 65,536 = ~1%. + +> **Note:** Unsettled CIDR Reservations are excluded from the utilization percentage but are still accounted for when determining available space for new allocations. + +#### Virtual Network Utilization + +Virtual network utilization shows how much of a vNet's address space has been divided into subnets. A vNet with a large address prefix but only a few small subnets will show low utilization, indicating room for additional subnets. + +```text +vNet Utilization = Sum of Subnet Prefix Sizes / vNet Address Space Size +``` + +#### Subnet Utilization + +Subnet utilization shows how many IP addresses within a subnet are actually in use. This is calculated by counting the number of IP configurations (endpoints such as NICs, private endpoints, and other attached resources) plus the 5 addresses that Azure reserves in every subnet. + +```text +Subnet Utilization = (IP Configurations + 5 Reserved Addresses) / Subnet Prefix Size +``` + +The 5 reserved addresses account for the network address, default gateway, Azure DNS addresses, and the broadcast address, which Azure reserves in every subnet regardless of size. + +### Managing Associations via the API + +All Virtual Network Association operations are also available through the Azure IPAM REST API. For the full list of available endpoints and example calls, please see the [Virtual Network Associations](/api/README.md#virtual-network-associations) section of the API documentation. + +### Tips and Best Practices + +- **Associate before you plan**: Associate your existing virtual networks with their corresponding Blocks as soon as you set up Azure IPAM. This gives you an accurate utilization baseline from day one. +- **Use Reservations for new networks**: Rather than creating a virtual network in Azure and then manually associating it, use the Reservation workflow. This ensures the address space is held for you and the association happens automatically. +- **Clean up stale associations**: Periodically check for stale (red) associations and remove them. These represent virtual networks that no longer exist in Azure and inflate your association count. +- **Multi-prefix virtual networks**: Azure virtual networks can have multiple address prefixes. Because Blocks within a Space cannot have overlapping CIDRs, each prefix naturally falls under at most one Block. A virtual network with prefixes spanning multiple Blocks can be associated with each Block independently — only the prefix(es) within each Block's CIDR range will count toward that Block's utilization. +- **Watch for overlap**: If you can't associate a virtual network, check the Block's Reservations and External Networks for CIDR overlaps. An unsettled Reservation holding the same address space will block the association. + +## Reservations + +A **Reservation** allows you to claim a CIDR range within a **Block** before you actually create an Azure virtual Network. Think of it like placing a hold on address space, the reserved CIDR is excluded from future allocations (including new Reservations, virtual network associations, and External Networks) until the Reservation is either fulfilled or cancelled. + +Reservations are useful when you need to coordinate network creation across teams or processes. For example, a platform team might reserve a /24 for a project team that isn't ready to deploy their virtual network yet. The reserved space is guaranteed to be available when they need it. + +### How Reservations Work + +When you create a Reservation, Azure IPAM finds the next available CIDR range of the requested size within the target Block and marks it as reserved. The Reservation is assigned a unique **Reservation ID** and a status of `Waiting`. From there, the Reservation follows a lifecycle: + +1. **Waiting** — The Reservation is active and the CIDR is held. IPAM is watching for a virtual network tagged with the Reservation ID. +2. **Fulfilled** — A virtual network with the Reservation's `X-IPAM-RES-ID` tag was discovered, its address space matches the reserved CIDR, and it has been automatically associated with the Block. +3. **Cancelled by User** — An administrator or the user who created the Reservation manually cancelled it. +4. **Cancelled by Timeout** — The Reservation expired based on the configured timeout policy. +5. **Warning: CIDR Mismatch** — A virtual network with the Reservation's tag was found, but its address space does not match the reserved CIDR. +6. **Error: CIDR Overlap** — A virtual network with an overlapping CIDR has already been associated with the Block through another path. + +> **Tip:** When a Reservation is created, the response includes a `tag` object containing `X-IPAM-RES-ID`. Apply this tag to your new virtual network and Azure IPAM will automatically detect it, associate the vNET with the Block, and mark the reservation as fulfilled. + +### Reservation Permissions + +Both Azure IPAM administrators and regular users can create and manage Reservations. However, non-admin users can only see and manage Reservations they created themselves. Administrators can view and manage all Reservations across all users. + +### Managing Reservations via the UI + +Reservations are managed from the **Configure** section of the Azure IPAM menu blade. Navigate to **Configure → Reservations** to access the Reservations management page. + +![Reservations Navigation](./images/resv_nav_configure_reservations.png) + +#### The Reservations Page + +The Reservations page displays a table of all reservations for a given Block. + +![Reservations Page](./images/resv_configure_page.png) + +You must select both a **Space** and a **Block** before you can view or manage reservations. + +> **Shortcut:** You can also navigate directly to Reservations from the **Configure → Basics** page. Select a Block, open the action menu (3 ellipses), and choose **Reservations**. This will take you to the Reservations tab with the Space and Block pre-selected. + +#### Viewing Reservations + +The table shows the following information for each Reservation: + +- **CIDR** — The reserved CIDR range +- **Created By** — The user or service principal that created the Reservation +- **Description** — An optional description provided at creation time +- **Creation Date** — When the Reservation was created +- **Settled Date** — When the Reservation was fulfilled or cancelled (hidden by default) +- **Settled By** — Who or what settled the Reservation (hidden by default) +- **Status** — A status icon indicating the current state of the Reservation + +#### Filtering Active vs. Settled Reservations + +By default, the table shows only **active** (unsettled) Reservations. To view all Reservations, including those that have been fulfilled or cancelled, open the action menu and click **Showing Active** to toggle to **Showing All**. Click it again to switch back to active-only view. + +![Toggle Reservation Filter](./images/resv_toggle_filter.png) + +#### Creating a Reservation + +To create a new Reservation, open the action menu (down chevron) and select **New Reservation**. You must have a **Space** and **Block** selected before this option becomes available. + +![New Reservation Menu](./images/resv_new_reservation_menu.png) + +The **Create Reservation** dialog gives you two ways to specify the CIDR range. Use the **Allocation Mode** toggle to switch between them: + +**Auto (default):** + +Azure IPAM automatically finds the next available CIDR of the requested size. Choose a subnet **Mask** from the dropdown (available masks are based on the Block's CIDR range). You can also configure two optional search behaviors: + +- **Reverse Search** — When enabled, Azure IPAM allocates from the *end* of the Block rather than the beginning. This is useful when you want to keep the beginning of the Block available for larger allocations. +- **Smallest CIDR** — When enabled, Azure IPAM uses the *smallest available* contiguous block that fits the requested size, rather than the first one it finds. This helps avoid fragmenting large open ranges. + +![Create Reservation Auto](./images/resv_create_by_size.png) + +**Manual:** + +Enter a specific CIDR range in standard notation (e.g., `10.1.5.0/24`). The CIDR must be within the Block's range and cannot overlap any existing virtual networks, Reservations, or External Networks. + +![Create Reservation Manual](./images/resv_create_by_cidr.png) + +Optionally, you can add a **Description** to help identify the purpose of the Reservation. + +Click **Create** to submit the Reservation. On success, you'll see a confirmation notification and the new Reservation will appear in the table. + +#### Copying a Reservation ID + +Each active Reservation has a copy icon in the actions column. Click it to copy the **Reservation ID** to your clipboard. You'll need this ID to tag your virtual network so Azure IPAM can automatically associate it with the Block when the virtual nertwork is created. + +![Copy Reservation ID](./images/resv_copy_id.png) + +> **Tip:** The copy icon is only available for unsettled Reservations. Once a Reservation is fulfilled or cancelled, the ID is no longer actionable. + +#### Cancelling Reservations + +To cancel one or more Reservations, select them using the checkboxes in the table, then click the **Remove** button (red X icon) in the upper-right corner of the page. + +![Cancel Reservations](./images/resv_cancel_selected.png) + +Cancelled Reservations are not completely removed, instead they are marked as **Cancelled by User** and remain visible when viewing all Reservations. This provides an audit trail of Reservation activity. + +### Using the Reservation Tag + +The key to automating the Reservation workflow is the `X-IPAM-RES-ID` tag. When you create a Reservation, Azure IPAM returns a tag value in the response. Apply this tag to the Azure virtual network you create with the reserved CIDR: + +```text +Tag Key: X-IPAM-RES-ID +Tag Value: +``` + +Azure IPAM's background reconciliation process periodically scans for virtual networks with this tag. When it finds a match, it: + +1. Verifies the virtual network's address space against the reserved CIDR +2. Associates the virtual network with the Block +3. Marks the Reservation as **Fulfilled** + +This tag-based approach means you can create the Reservation through Azure IPAM and then create the virtual network through any mechanism you prefer: Azure Portal, CLI, PowerShell, Terraform, Bicep, or any other IaC tool. + +### Multiple Reservations on a Single Virtual Network + +Azure Virtual Networks support multiple address spaces (prefixes). If your virtual network has more than one address space, each address space that falls within a Block must have its own Reservation. The `X-IPAM-RES-ID` tag supports this by accepting a **comma-separated list** of Reservation IDs in a single tag value: + +```text +Tag Key: X-IPAM-RES-ID +Tag Value: ,, +``` + +The reconciliation engine strips all whitespace from the tag value before parsing, so spaces around the commas are harmless, but the canonical format is no spaces: + +```text +Tag Key: X-IPAM-RES-ID +Tag Value: ABNsJjXXyTRDTRCdJEJThu,XKp7mQeNvLCsWbYdFgHiRZ +``` + +Each Reservation ID in the list is evaluated independently. For every ID found, the engine: + +1. Looks up the corresponding Reservation +2. Verifies that the virtual network has an address space matching the Reservation's reserved CIDR +3. Associates the virtual network with the Block (if not already associated) +4. Marks that individual Reservation as **Fulfilled** + +Because each Reservation ID is processed separately, a virtual network with two address spaces can carry two Reservation IDs in a single tag and both Reservations will be fulfilled in the same reconciliation cycle. + +> **Note:** Each Reservation ID in the list is evaluated independently. The IDs do not need to belong to the same Block — a virtual network with two address spaces can have one address space associated to one Block and the other to an entirely different Block (or even a different Space), each with its own Reservation ID in the list. A mismatch between a reserved CIDR and the virtual network's address space will result in a `Warning: CIDR Mismatch` status for that individual Reservation, regardless of the other Reservation IDs in the list. + +### Managing Reservations via the API + +All Reservation operations are also available through the Azure IPAM REST API. For the full list of available endpoints and example calls, please see the [Reservations](/api/README.md#reservations) section of the API documentation. + +### Tips and Best Practices + +- **Use descriptions**: Always add a description when creating a Reservation so it's clear what the Reservation is for, especially in environments with multiple teams +- **Copy the tag immediately**: After creating a Reservation, copy the Reservation ID right away and store it somewhere accessible. You'll need it to tag your virtual network. +- **Match your CIDR exactly**: When creating a virtual network to fulfill a Reservation, make sure the virtual network's address space matches the reserved CIDR exactly. A mismatch will result in a warning status. +- **Monitor reservation status**: Check in on your Reservations periodically. A Reservation stuck in "Waiting" may indicate the virtual network was created without the proper tag. +- **Use Reverse Search for large Blocks**: If you have a large Block and want to avoid fragmenting the beginning of the range, enable **Reverse Search** to allocate from the end. +- **Use Smallest CIDR to reduce fragmentation**: Enable **Smallest CIDR** when you want to preserve larger contiguous ranges for future use +- **Multiple address spaces require multiple Reservations**: If your virtual network will have more than one address space, create a separate Reservation for each prefix and set the `X-IPAM-RES-ID` tag value to a comma-separated list of all Reservation IDs (e.g., `id1,id2`) + +## External Networks + +Azure IPAM is primarily designed to discover and manage IP address space within Azure. However, many organizations also need to track IP address utilization for networks that exist **outside of Azure**. This could include on-premises datacenter networks, co-location facilities, other cloud providers, or any IP space that is part of your overall enterprise addressing scheme but is not natively managed by Azure. + +**External Networks** in Azure IPAM were designed to address this need. They allow you to: + +- **Map non-Azure CIDR ranges** within your existing Blocks to indicate that the address space is allocated elsewhere +- **Define subnets** within those External Networks to represent the various network segments where endpoints reside +- **Track individual endpoints** within those subnets, including their names, descriptions, and IP addresses +- **Prevent address conflicts** by accounting for externally managed IP space when planning new Azure network deployments or creating CIDR reservations + +> **Note:** External Network management is an **IPAM Administrator** function. Only users designated as Azure IPAM admins can create, modify, or delete External Networks, Subnets, and Endpoints. However, all users with access to IPAM can view the Manage Endpoints dialog for external subnets. + +### How External Networks Fit Into the IPAM Hierarchy + +External Networks live within the existing **Space → Block** hierarchy. A **Block** represents a CIDR range and can contain Azure virtual networks, CIDR reservations, *and* External Networks. The full hierarchy looks like this: + +```text +Space +└── Block (e.g. 10.0.0.0/16) + ├── Azure Virtual Networks + ├── CIDR Reservations + └── External Networks (e.g. 10.0.100.0/24) + └── External Subnets (e.g. 10.0.100.0/26) + └── External Endpoints (e.g. 10.0.100.5) +``` + +When Azure IPAM calculates available address space within a Block (for example, when creating a new CIDR reservation or evaluating utilization), it accounts for External Networks alongside Azure virtual networks and existing Reservations. This ensures that externally allocated space is never accidentally double-assigned. + +### Managing External Networks via the UI + +External Networks are managed from the **Configure** section of the Azure IPAM menu blade. Navigate to **Configure → Externals** to access the External Networks management page. + +![External Networks Navigation](./images/ext_nav_configure_externals.png) + +#### The Externals Configuration Page + +The Externals page presents a split-pane view. The upper half displays **External Networks** for the selected Block, and the lower half displays **Subnets** for the currently selected External Network. + +At the top of the page, you'll find selectors for **Space** and **Block**. You must select both a Space and a Block before you can view or manage External Networks. + +![Externals Configuration Page](./images/ext_configure_page.png) + +#### Adding an External Network + +To add a new External Network, first select the target **Space** and **Block** using the dropdowns at the top. Then, open the action menu (down chevron) on the External Networks grid and select **Add Network**. + +![Add External Network Menu](./images/ext_add_network_menu.png) + +You will be presented with a dialog to define the new External Network: + +- **Name**: A unique name for the External Network (up to 64 characters; alphanumerics, underscores, hyphens, and periods are allowed) +- **Description**: A description of the External Network (up to 128 characters; alphanumerics, spaces, underscores, hyphens, slashes, and periods are allowed) +- **CIDR**: Use the **Allocation Mode** toggle to specify the network size in one of two ways: + - **Auto**: Select a subnet mask size, and Azure IPAM will automatically assign the next available CIDR within the Block + - **Manual**: Specify an exact CIDR range (must be within the parent Block and cannot overlap existing virtual networks, Reservations, or other External Networks) + +![Add External Network Dialog](./images/ext_add_network_dialog.png) + +Once created, the External Network will appear in the table, and its CIDR range will be accounted for in the Block's utilization metrics. + +#### Editing an External Network + +To edit an existing External Network, select the network in the table, then open the action menu and select **Edit Network**. + +![Edit External Network Menu](./images/ext_edit_network_menu.png) + +You can update the **Name**, **Description**, and **CIDR** of the External Network. The same validation rules apply as when creating a new network: the updated CIDR must remain within the parent Block and cannot overlap other Virtual Networks, External Networks, or unfulfilled Reservations. Additionally, if the External Network contains any External Subnets, the updated CIDR must be large enough to encompass all of those as well. + +#### Deleting an External Network + +To delete an External Network, select it in the table, open the action menu, and select **Delete Network**. + +![Delete External Network Menu](./images/ext_delete_network_menu.png) + +You will be asked to confirm the deletion. If the External Network contains subnets, you will need to enable the **Force Delete** option and confirm a second time before the deletion proceeds. + +![Delete External Network Dialog](./images/ext_delete_network_dialog.png) + +### Managing External Subnets + +External Subnets represent the individual network segments within an External Network. These could correspond to physical subnets in an on-premises datacenter, VLANs, or any other logical network division. + +#### Viewing External Subnets + +When you select an External Network in the upper table, the lower table will populate with its associated Subnets. Each subnet displays its **Name**, **Description**, and **Address Range** (CIDR). + +![External Subnets Grid](./images/ext_subnets_grid.png) + +#### Adding an External Subnet + +With an External Network selected, open the action menu on the Subnets table and select **Add Subnet**. + +![Add External Subnet Menu](./images/ext_add_subnet_menu.png) + +Define the subnet with the following details: + +- **Name**: A unique name within the parent External Network (up to 64 characters) +- **Description**: A description of the subnet (up to 128 characters) +- **CIDR**: Use the **Allocation Mode** toggle to specify either by **Auto** (automatic assignment) or by **Manual** (must fall within the parent External Network's CIDR range and cannot overlap sibling subnets) + +![Add External Subnet Dialog](./images/ext_add_subnet_dialog.png) + +#### Editing an External Subnet + +Select a Subnet in the lower table, open the action menu, and select **Edit Subnet** to modify its name, description, or CIDR. The updated CIDR must remain within the parent External Network, cannot overlap other External Subnets, and must be large enough to contain all existing Endpoints within the subnet. + +![Edit External Subnet Dialog](./images/ext_edit_subnet_dialog.png) + +#### Deleting an External Subnet + +Select a Subnet, open the action menu, and choose **Remove Subnet**. If the subnet contains endpoints, you will need to use the **Force Delete** option. + +![Delete External Subnet Menu](./images/ext_delete_subnet_menu.png) + +You will be asked to confirm the deletion. If the External Subnet contains endpoints, you will need to enable the **Force Delete** option and confirm a second time before the deletion proceeds. + +![Delete External Subnet Dialog](./images/ext_delete_subnet_dialog.png) + +### Managing External Endpoints + +External Endpoints represent individual hosts or devices within an External Subnet. This is where you can track specific machines, appliances, or services along with their IP assignments. + +#### Opening the Manage Endpoints View + +Select a Subnet in the lower table, then open the action menu and select **Manage Endpoints**. This opens a dialog box for managing all endpoints within the selected subnet. + +![Manage Endpoints Menu](./images/ext_manage_endpoints_menu.png) + +#### The Manage Endpoints Dialog + +The Manage Endpoints dialog is divided into two sections: + +1. **Add/Edit Form** (top): Fields for **Name**, **Description**, and **IP Address** with an action button to add or update an endpoint +2. **Existing Endpoints Table** (bottom): A table showing all current endpoints with their names, descriptions, and IP addresses + +![Manage Endpoints Dialog](./images/ext_manage_endpoints_dialog.png) + +#### Adding an Endpoint + +Fill in the endpoint details in the form at the top of the dialog: + +- **Name**: A unique name for the endpoint (up to 64 characters) +- **Description**: A description of the endpoint (up to 128 characters) +- **IP Address**: Select an available IP address from the dropdown, or choose **\** to have Azure IPAM assign the next available IP within the subnet + +Click **Add** to stage the endpoint. You can add multiple endpoints before saving. + +![Add Endpoint Dialog](./images/ext_add_endpoint_dialog.png) + +You can review all staged additional endpoints before clicking **Save** to commit them. + +![Add Endpoint Dialog Save](./images/ext_add_endpoint_dialog_save.png) + +> **Tip:** The IP Address dropdown automatically shows only the available (unassigned) IP addresses within the subnet's CIDR range. + +#### Editing an Endpoint + +Click on an existing endpoint row in the table to load it into the form at the top. Modify the desired fields, then click **Update** to stage the change. If updating the IP address, the new address must still fall within the parent subnet's CIDR and cannot duplicate another endpoint's IP in the same subnet. + +![Update External Endpoint Dialog](./images/ext_update_endpoint_dialog.png) + +You can review all staged endpoints updates before clicking **Save** to commit them. + +![Update External Endpoint Dialog Save](./images/ext_update_endpoint_dialog_save.png) + +#### Deleting Endpoints + +To delete an endpoint, first click the row in the table to select it. The delete icon will appear in that row once it is selected. Click the delete icon to stage the endpoint for deletion. + +![Delete External Endpoint Dialog](./images/ext_delete_endpoint_dialog.png) + +You can review all staged endpoint deletions before clicking **Save** to commit them. + +![Delete External Endpoint Dialog Save](./images/ext_delete_endpoint_dialog_save.png) + +#### Saving Endpoint Changes + +All endpoint changes (additions, updates, and deletions) are staged locally in the dialog before being committed. This allows you to perform multiple operations in a single batch — for example, you can add new endpoints, update existing ones, and delete others all in the same dialog session. Once you are satisfied with all your changes, click **Save** to commit them all at once. This replaces the full endpoint list for the subnet in a single operation. + +### Managing External Networks via the API + +All External Network operations are also exposed via the Azure IPAM REST API. You can manage External Networks, Subnets, and Endpoints programmatically just as you would any other Azure IPAM resource. For the full list of available API endpoints and example calls, please see the [External Networks](/api/README.md#external-networks) section of the API documentation. + +Additionally, for guidance on integrating External Network management into automated workflows, see the [Automation](/automation/README.md) documentation. + +### Tips and Best Practices + +- **Use descriptive names**: Name your External Networks and External Subnets in a way that makes their physical or logical location immediately clear (e.g., `DC1-Floor2-ServerVLAN`, `AWS-US-East-1-VPC`) +- **Keep it current**: External Networks are only as useful as they are accurate. Consider automating synchronization with your existing network management tools +- **Leverage auto-assignment**: When adding endpoints, use the auto-assign IP feature (`ip: null` in the API, or `` in the UI) to let IPAM track the next available address +- **Plan before you allocate**: Since External Network CIDRs are accounted for in Block utilization calculations, adding them *before* creating new Azure virtual networks ensures you won't accidentally encounter overlap +- **Use force delete judiciously**: The force delete option on External Networks and External Subnets will remove all child objects. Use it carefully, especially in production environments diff --git a/docs/how-to/images/SCREENSHOTS_NEEDED.md b/docs/how-to/images/SCREENSHOTS_NEEDED.md new file mode 100644 index 00000000..622ab0db --- /dev/null +++ b/docs/how-to/images/SCREENSHOTS_NEEDED.md @@ -0,0 +1,86 @@ +# Placeholder Screenshots for Documentation + +The following screenshots are needed for various documentation pages. Each filename corresponds to an image reference in `../README.md`. + +--- + +## Administration + +- [ ] `ipam_admin_user_search.png` — Admin page with the User/Principal toggle set to **User**, showing the "User Search" label in the search bar +- [ ] `ipam_admin_principal_search.png` — Admin page with the toggle set to **Principal**, showing the "Principal Search" label in the search bar +- [ ] `ipam_admin_search_results.png` — Admin search bar with a name typed and the autocomplete dropdown showing matching results + +--- + +## Reservations + +### Navigation & Page Layout + +- [ ] `resv_nav_configure_reservations.png` — IPAM sidebar showing Configure → Reservations navigation path +- [ ] `resv_configure_page.png` — Full Reservations page with Space/Block/Network selectors and empty grid (showing "Please Select a Space & Block") + +### Viewing Reservations + +- [ ] `resv_grid_with_data.png` — Reservations grid populated with several reservations in various states (waiting, fulfilled, cancelled) +- [ ] `resv_toggle_filter.png` — Action menu showing the "Showing Active" / "Showing All" toggle option + +### Creating Reservations + +- [ ] `resv_new_reservation_menu.png` — Action menu showing the "New Reservation" option +- [ ] `resv_create_by_size.png` — Create Reservation dialog with the "By Size" radio selected, showing the Mask dropdown, Reverse Search toggle, and Smallest CIDR toggle +- [ ] `resv_create_by_cidr.png` — Create Reservation dialog with the "By CIDR" radio selected, showing the CIDR text field + +### Managing Reservations + +- [ ] `resv_copy_id.png` — Reservation row showing the copy icon in the actions column (ideally with the tooltip visible) +- [ ] `resv_cancel_selected.png` — Reservations grid with one or more rows selected via checkboxes, showing the red Remove (X) button in the upper-right + +### Reservation Screenshot Tips + +- Use realistic data (e.g., reservations like "10.1.5.0/24" with descriptions like "Project Alpha vNET") +- Include reservations in multiple statuses if possible (Waiting, Fulfilled, Cancelled) for the grid screenshot +- For the Create dialog screenshots, show both radio options clearly — one with "By Size" selected and one with "By CIDR" selected +- Make sure the Space/Block selectors are populated so users can see the full context + +--- + +## External Networks + +- [ ] `ext_nav_configure_externals.png` — IPAM sidebar showing Configure → Externals navigation path +- [ ] `ext_configure_page.png` — Full Externals configuration page with Space/Block selectors, External Networks grid (top), and Subnets grid (bottom) + +## External Networks (CRUD) + +- [ ] `ext_add_network_menu.png` — Action menu showing "Add Network" option on the External Networks grid +- [ ] `ext_add_network_dialog.png` — Add External Network dialog with Name, Description, and Size/CIDR fields +- [ ] `ext_edit_network_menu.png` — Action menu showing "Edit Network" option (with a network selected) +- [ ] `ext_edit_network_dialog.png` — Edit External Network dialog with pre-populated fields +- [ ] `ext_delete_network_menu.png` — Action menu showing "Delete Network" option +- [ ] `ext_delete_network_dialog.png` — Delete External Network confirmation dialog (ideally showing the Force Delete checkbox) + +## External Subnets (CRUD) + +- [ ] `ext_subnets_grid.png` — Subnets grid populated with subnets for a selected External Network +- [ ] `ext_add_subnet_menu.png` — Action menu showing "Add Subnet" option on the Subnets grid +- [ ] `ext_add_subnet_dialog.png` — Add External Subnet dialog +- [ ] `ext_edit_subnet_dialog.png` — Edit External Subnet dialog +- [ ] `ext_delete_subnet_dialog.png` — Delete External Subnet confirmation dialog + +## External Endpoints + +- [ ] `ext_manage_endpoints_menu.png` — Action menu showing "Manage Endpoints" option on the Subnets grid +- [ ] `ext_manage_endpoints_dialog.png` — Full Manage Endpoints dialog showing the add/edit form at top and endpoints grid at bottom +- [ ] `ext_add_endpoint_form.png` — Close-up of the endpoint add form with Name, Description, and IP Address (showing the IP dropdown with available addresses) +- [ ] `ext_save_endpoints.png` — Manage Endpoints dialog with staged changes ready to save + +## External Network Screenshot Tips + +- Use a realistic-looking dataset (e.g., "OnPrem-DC1" with subnets like "ServerVLAN", "DesktopVLAN" and endpoints like "db-server-01") +- Ensure the Space/Block selectors are populated in all screenshots so users can see the full context +- For the Force Delete screenshots, show both the initial state and the confirmed state if possible + +--- + +## Virtual Hubs (vHubs) + +- [ ] `discover_vhubs.png` — Discover → vHubs tab showing the vHubs data grid with columns: Name, Virtual WAN, Block, Prefixes, Resource Group (ideally with at least one vHub associated to a Block) diff --git a/docs/how-to/images/add_block.png b/docs/how-to/images/add_block.png index 80a7677a..18a031ad 100644 Binary files a/docs/how-to/images/add_block.png and b/docs/how-to/images/add_block.png differ diff --git a/docs/how-to/images/add_block_details.png b/docs/how-to/images/add_block_details.png index 83466ac4..40813210 100644 Binary files a/docs/how-to/images/add_block_details.png and b/docs/how-to/images/add_block_details.png differ diff --git a/docs/how-to/images/add_space.png b/docs/how-to/images/add_space.png index 3687f9ad..5a24d320 100644 Binary files a/docs/how-to/images/add_space.png and b/docs/how-to/images/add_space.png differ diff --git a/docs/how-to/images/add_space_details.png b/docs/how-to/images/add_space_details.png index bd477e9c..d986bbdc 100644 Binary files a/docs/how-to/images/add_space_details.png and b/docs/how-to/images/add_space_details.png differ diff --git a/docs/how-to/images/discover_blocks.png b/docs/how-to/images/discover_blocks.png index e71b8e5d..816c27c0 100644 Binary files a/docs/how-to/images/discover_blocks.png and b/docs/how-to/images/discover_blocks.png differ diff --git a/docs/how-to/images/discover_endpoints.png b/docs/how-to/images/discover_endpoints.png index 8afa9dda..cd5cff5c 100644 Binary files a/docs/how-to/images/discover_endpoints.png and b/docs/how-to/images/discover_endpoints.png differ diff --git a/docs/how-to/images/discover_endpoints_details.png b/docs/how-to/images/discover_endpoints_details.png index a7208ae4..7819273a 100644 Binary files a/docs/how-to/images/discover_endpoints_details.png and b/docs/how-to/images/discover_endpoints_details.png differ diff --git a/docs/how-to/images/discover_spaces.png b/docs/how-to/images/discover_spaces.png index 603f273e..08384e65 100644 Binary files a/docs/how-to/images/discover_spaces.png and b/docs/how-to/images/discover_spaces.png differ diff --git a/docs/how-to/images/discover_subnets.png b/docs/how-to/images/discover_subnets.png index 4f544a6c..5d0eaa97 100644 Binary files a/docs/how-to/images/discover_subnets.png and b/docs/how-to/images/discover_subnets.png differ diff --git a/docs/how-to/images/discover_subnets_details.png b/docs/how-to/images/discover_subnets_details.png index 5fb5a06d..e24f3767 100644 Binary files a/docs/how-to/images/discover_subnets_details.png and b/docs/how-to/images/discover_subnets_details.png differ diff --git a/docs/how-to/images/discover_vhubs.png b/docs/how-to/images/discover_vhubs.png new file mode 100644 index 00000000..6b50c070 Binary files /dev/null and b/docs/how-to/images/discover_vhubs.png differ diff --git a/docs/how-to/images/discover_vhubs_details.png b/docs/how-to/images/discover_vhubs_details.png new file mode 100644 index 00000000..8fa9dff4 Binary files /dev/null and b/docs/how-to/images/discover_vhubs_details.png differ diff --git a/docs/how-to/images/discover_vnets.png b/docs/how-to/images/discover_vnets.png index b9fa9839..03411c5f 100644 Binary files a/docs/how-to/images/discover_vnets.png and b/docs/how-to/images/discover_vnets.png differ diff --git a/docs/how-to/images/discover_vnets_details.png b/docs/how-to/images/discover_vnets_details.png index b76fab3b..84c0ce2b 100644 Binary files a/docs/how-to/images/discover_vnets_details.png and b/docs/how-to/images/discover_vnets_details.png differ diff --git a/docs/how-to/images/ext_add_endpoint_dialog.png b/docs/how-to/images/ext_add_endpoint_dialog.png new file mode 100644 index 00000000..02fb3a17 Binary files /dev/null and b/docs/how-to/images/ext_add_endpoint_dialog.png differ diff --git a/docs/how-to/images/ext_add_endpoint_dialog_save.png b/docs/how-to/images/ext_add_endpoint_dialog_save.png new file mode 100644 index 00000000..41a4bffa Binary files /dev/null and b/docs/how-to/images/ext_add_endpoint_dialog_save.png differ diff --git a/docs/how-to/images/ext_add_network_dialog.png b/docs/how-to/images/ext_add_network_dialog.png new file mode 100644 index 00000000..57a4c69c Binary files /dev/null and b/docs/how-to/images/ext_add_network_dialog.png differ diff --git a/docs/how-to/images/ext_add_network_menu.png b/docs/how-to/images/ext_add_network_menu.png new file mode 100644 index 00000000..f8b2b2f1 Binary files /dev/null and b/docs/how-to/images/ext_add_network_menu.png differ diff --git a/docs/how-to/images/ext_add_subnet_dialog.png b/docs/how-to/images/ext_add_subnet_dialog.png new file mode 100644 index 00000000..43af5869 Binary files /dev/null and b/docs/how-to/images/ext_add_subnet_dialog.png differ diff --git a/docs/how-to/images/ext_add_subnet_menu.png b/docs/how-to/images/ext_add_subnet_menu.png new file mode 100644 index 00000000..0bac8192 Binary files /dev/null and b/docs/how-to/images/ext_add_subnet_menu.png differ diff --git a/docs/how-to/images/ext_configure_page.png b/docs/how-to/images/ext_configure_page.png new file mode 100644 index 00000000..3731ea33 Binary files /dev/null and b/docs/how-to/images/ext_configure_page.png differ diff --git a/docs/how-to/images/ext_delete_endpoint_dialog.png b/docs/how-to/images/ext_delete_endpoint_dialog.png new file mode 100644 index 00000000..8eb28668 Binary files /dev/null and b/docs/how-to/images/ext_delete_endpoint_dialog.png differ diff --git a/docs/how-to/images/ext_delete_endpoint_dialog_save.png b/docs/how-to/images/ext_delete_endpoint_dialog_save.png new file mode 100644 index 00000000..062f2776 Binary files /dev/null and b/docs/how-to/images/ext_delete_endpoint_dialog_save.png differ diff --git a/docs/how-to/images/ext_delete_network_dialog.png b/docs/how-to/images/ext_delete_network_dialog.png new file mode 100644 index 00000000..ea1eddb4 Binary files /dev/null and b/docs/how-to/images/ext_delete_network_dialog.png differ diff --git a/docs/how-to/images/ext_delete_network_menu.png b/docs/how-to/images/ext_delete_network_menu.png new file mode 100644 index 00000000..cf24033d Binary files /dev/null and b/docs/how-to/images/ext_delete_network_menu.png differ diff --git a/docs/how-to/images/ext_delete_subnet_dialog.png b/docs/how-to/images/ext_delete_subnet_dialog.png new file mode 100644 index 00000000..d26ab2d4 Binary files /dev/null and b/docs/how-to/images/ext_delete_subnet_dialog.png differ diff --git a/docs/how-to/images/ext_delete_subnet_menu.png b/docs/how-to/images/ext_delete_subnet_menu.png new file mode 100644 index 00000000..0dea013f Binary files /dev/null and b/docs/how-to/images/ext_delete_subnet_menu.png differ diff --git a/docs/how-to/images/ext_edit_network_menu.png b/docs/how-to/images/ext_edit_network_menu.png new file mode 100644 index 00000000..cff3b799 Binary files /dev/null and b/docs/how-to/images/ext_edit_network_menu.png differ diff --git a/docs/how-to/images/ext_edit_subnet_dialog.png b/docs/how-to/images/ext_edit_subnet_dialog.png new file mode 100644 index 00000000..74c67b19 Binary files /dev/null and b/docs/how-to/images/ext_edit_subnet_dialog.png differ diff --git a/docs/how-to/images/ext_manage_endpoints_dialog.png b/docs/how-to/images/ext_manage_endpoints_dialog.png new file mode 100644 index 00000000..93de1c26 Binary files /dev/null and b/docs/how-to/images/ext_manage_endpoints_dialog.png differ diff --git a/docs/how-to/images/ext_manage_endpoints_menu.png b/docs/how-to/images/ext_manage_endpoints_menu.png new file mode 100644 index 00000000..26e509b7 Binary files /dev/null and b/docs/how-to/images/ext_manage_endpoints_menu.png differ diff --git a/docs/how-to/images/ext_nav_configure_externals.png b/docs/how-to/images/ext_nav_configure_externals.png new file mode 100644 index 00000000..f9912524 Binary files /dev/null and b/docs/how-to/images/ext_nav_configure_externals.png differ diff --git a/docs/how-to/images/ext_subnets_grid.png b/docs/how-to/images/ext_subnets_grid.png new file mode 100644 index 00000000..3a6f1ce7 Binary files /dev/null and b/docs/how-to/images/ext_subnets_grid.png differ diff --git a/docs/how-to/images/ext_update_endpoint_dialog.png b/docs/how-to/images/ext_update_endpoint_dialog.png new file mode 100644 index 00000000..6997616d Binary files /dev/null and b/docs/how-to/images/ext_update_endpoint_dialog.png differ diff --git a/docs/how-to/images/ext_update_endpoint_dialog_save.png b/docs/how-to/images/ext_update_endpoint_dialog_save.png new file mode 100644 index 00000000..2d1bab85 Binary files /dev/null and b/docs/how-to/images/ext_update_endpoint_dialog_save.png differ diff --git a/docs/how-to/images/ipam_admin_admins.png b/docs/how-to/images/ipam_admin_admins.png index 98eb40dd..94538d9f 100644 Binary files a/docs/how-to/images/ipam_admin_admins.png and b/docs/how-to/images/ipam_admin_admins.png differ diff --git a/docs/how-to/images/ipam_admin_principal_search.png b/docs/how-to/images/ipam_admin_principal_search.png new file mode 100644 index 00000000..69a772e5 Binary files /dev/null and b/docs/how-to/images/ipam_admin_principal_search.png differ diff --git a/docs/how-to/images/ipam_admin_search_results.png b/docs/how-to/images/ipam_admin_search_results.png new file mode 100644 index 00000000..87f7af17 Binary files /dev/null and b/docs/how-to/images/ipam_admin_search_results.png differ diff --git a/docs/how-to/images/ipam_admin_subscriptions.png b/docs/how-to/images/ipam_admin_subscriptions.png index 383bfb57..3487f938 100644 Binary files a/docs/how-to/images/ipam_admin_subscriptions.png and b/docs/how-to/images/ipam_admin_subscriptions.png differ diff --git a/docs/how-to/images/ipam_admin_subscriptions_config.png b/docs/how-to/images/ipam_admin_subscriptions_config.png index 8a42be8e..a4725213 100644 Binary files a/docs/how-to/images/ipam_admin_subscriptions_config.png and b/docs/how-to/images/ipam_admin_subscriptions_config.png differ diff --git a/docs/how-to/images/ipam_admin_user_delete.png b/docs/how-to/images/ipam_admin_user_delete.png new file mode 100644 index 00000000..dc02d233 Binary files /dev/null and b/docs/how-to/images/ipam_admin_user_delete.png differ diff --git a/docs/how-to/images/ipam_admin_user_search.png b/docs/how-to/images/ipam_admin_user_search.png new file mode 100644 index 00000000..0ce3e77f Binary files /dev/null and b/docs/how-to/images/ipam_admin_user_search.png differ diff --git a/docs/how-to/images/ipam_administrators_config.png b/docs/how-to/images/ipam_administrators_config.png deleted file mode 100644 index e86612a4..00000000 Binary files a/docs/how-to/images/ipam_administrators_config.png and /dev/null differ diff --git a/docs/how-to/images/ipam_configure.png b/docs/how-to/images/ipam_configure.png deleted file mode 100644 index 7f7c677e..00000000 Binary files a/docs/how-to/images/ipam_configure.png and /dev/null differ diff --git a/docs/how-to/images/ipam_discover.png b/docs/how-to/images/ipam_discover.png deleted file mode 100644 index 79b383c8..00000000 Binary files a/docs/how-to/images/ipam_discover.png and /dev/null differ diff --git a/docs/how-to/images/home_page.png b/docs/how-to/images/ipam_home_page.png similarity index 100% rename from docs/how-to/images/home_page.png rename to docs/how-to/images/ipam_home_page.png diff --git a/docs/how-to/images/resv_cancel_selected.png b/docs/how-to/images/resv_cancel_selected.png new file mode 100644 index 00000000..442cef92 Binary files /dev/null and b/docs/how-to/images/resv_cancel_selected.png differ diff --git a/docs/how-to/images/resv_configure_page.png b/docs/how-to/images/resv_configure_page.png new file mode 100644 index 00000000..236996c0 Binary files /dev/null and b/docs/how-to/images/resv_configure_page.png differ diff --git a/docs/how-to/images/resv_copy_id.png b/docs/how-to/images/resv_copy_id.png new file mode 100644 index 00000000..341112e6 Binary files /dev/null and b/docs/how-to/images/resv_copy_id.png differ diff --git a/docs/how-to/images/resv_create_by_cidr.png b/docs/how-to/images/resv_create_by_cidr.png new file mode 100644 index 00000000..301d8f4d Binary files /dev/null and b/docs/how-to/images/resv_create_by_cidr.png differ diff --git a/docs/how-to/images/resv_create_by_size.png b/docs/how-to/images/resv_create_by_size.png new file mode 100644 index 00000000..c3e21e26 Binary files /dev/null and b/docs/how-to/images/resv_create_by_size.png differ diff --git a/docs/how-to/images/resv_nav_configure_reservations.png b/docs/how-to/images/resv_nav_configure_reservations.png new file mode 100644 index 00000000..6211cb7a Binary files /dev/null and b/docs/how-to/images/resv_nav_configure_reservations.png differ diff --git a/docs/how-to/images/resv_new_reservation_menu.png b/docs/how-to/images/resv_new_reservation_menu.png new file mode 100644 index 00000000..f5c5f3d7 Binary files /dev/null and b/docs/how-to/images/resv_new_reservation_menu.png differ diff --git a/docs/how-to/images/resv_toggle_filter.png b/docs/how-to/images/resv_toggle_filter.png new file mode 100644 index 00000000..40fd044f Binary files /dev/null and b/docs/how-to/images/resv_toggle_filter.png differ diff --git a/docs/how-to/images/virtual_network_association.png b/docs/how-to/images/virtual_network_association.png index 7b2a8b84..2cd43640 100644 Binary files a/docs/how-to/images/virtual_network_association.png and b/docs/how-to/images/virtual_network_association.png differ diff --git a/docs/how-to/images/virtual_network_association_details.png b/docs/how-to/images/virtual_network_association_details.png index 417be9d2..a267e77d 100644 Binary files a/docs/how-to/images/virtual_network_association_details.png and b/docs/how-to/images/virtual_network_association_details.png differ diff --git a/docs/how-to/images/virtual_network_association_stale.png b/docs/how-to/images/virtual_network_association_stale.png new file mode 100644 index 00000000..efe28b8c Binary files /dev/null and b/docs/how-to/images/virtual_network_association_stale.png differ diff --git a/docs/how-to/images/virtual_network_association_update.png b/docs/how-to/images/virtual_network_association_update.png new file mode 100644 index 00000000..c21247ce Binary files /dev/null and b/docs/how-to/images/virtual_network_association_update.png differ diff --git a/docs/migration/README.md b/docs/migration/README.md index d0503081..8dd472de 100644 --- a/docs/migration/README.md +++ b/docs/migration/README.md @@ -42,6 +42,7 @@ To successfully migrate your Azure IPAM deployment, the following prerequisites - Required to clone the Azure IPAM GitHub repository - [PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) version 7.2.0 or later installed - [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 11.4.0 or later installed +- [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) version 0.21.1 or later installed - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) version 2.35.0 or later installed (optional) - Required only if your deployment uses a private Azure Container Registry (Private ACR) @@ -95,7 +96,7 @@ If you want to configure custom backups with your own storage account and schedu 5. Optionally you can setup a custom back schedule by selecting the **Set Schedule** checkbox - **Repeats Every**: Set frequency (at least Daily recommended) - **Start Time**: Start time of the backup schedule - - **Time Zone**: UTC or local (poral) time zone + - **Time Zone**: UTC or local (portal) time zone - **Retention**: Set retention period (at least 30 days recommended) 6. Click **Configure** to enable custom backups @@ -107,7 +108,7 @@ If you want to run an on-demand backup, you must first configure **Custom Backup 1. **Navigate to your App Service** in the Azure Portal 2. Go to **Settings** → **Backups** -3. Click **Backup Now** to create an immediate backupstart the backup process +3. Click **Backup Now** to start the backup process 4. Monitor the backup status until it shows **Succeeded** ![Create On-Demand Backup](./images/create_on_demand_backup.png) @@ -174,7 +175,7 @@ az account set --subscription "" > **Important**: Ensure both Azure PowerShell and Azure CLI are authenticated to the **same subscription**. Mismatched subscription contexts can cause deployment failures during the migration process. -## Clone the Github Repo +## Clone the GitHub Repo ```powershell # Example using PowerShell for Windows @@ -341,7 +342,7 @@ After migration completes, verify your Azure IPAM deployment: 1. **Check Application Health**: - Verify the App Service is healthy - - ![Check App Service Helath](./images/app_service_health.png) + - ![Check App Service Health](./images/app_service_health.png) 2. **Check Container Configuration**: - Verify the App Service is no longer using Docker Compose @@ -381,11 +382,11 @@ az account show 4. Manually build & push new containers to ACR ```shell -# App Services Container +# App Container (Debian-based) az acr build -r -t ipam:latest -f ./Dockerfile.deb . -# Function Container -az acr build -r -t ipamfunc:latest -f ./Dockerfile.func . +# App Container (RHEL-based) +az acr build -r -t ipam:latest -f ./Dockerfile.rhel . ``` #### Issue: App Service fails to start after migration @@ -407,11 +408,11 @@ If you encounter issues during migration: ## Rollback Procedures -In the evant a migration issue should occur, you can rollback to your previous configuration: +In the event a migration issue should occur, you can rollback to your previous configuration: 1. **Navigate to your App Service** in the Azure Portal 2. Go to **Settings** → **Backups** -3. Locate a backup from **brefore** the Migration script was run +3. Locate a backup from **before** the Migration script was run 4. Click **Restore** icon associated with the target backup timestamp 5. Make sure to select the **existing** deployment slot, and select **Restore Site Configuration** 6. The restoration process can take *up to 30 minutes*, and will stop/start the App Service during that time diff --git a/docs/questions-comments/README.md b/docs/questions-comments/README.md index f5bb639b..722da7af 100644 --- a/docs/questions-comments/README.md +++ b/docs/questions-comments/README.md @@ -1,11 +1,22 @@ # Questions or Comments -The Azure IPAM team welcomes engagement and contributions from the community. +The Azure IPAM team welcomes engagement and contributions from the community. Whether you have a question, an idea for a new feature, or have found a bug, there are several ways to get in touch. ## Discussions -We have set up a [GitHub Discussions](https://github.com/Azure/ipam/discussions) page to make it easy to engage with the Azure IPAM team without opening an issue. +We have set up a [GitHub Discussions](https://github.com/Azure/ipam/discussions) page to make it easy to engage with the Azure IPAM team without opening an issue. This is the best place for: + +- General questions about Azure IPAM usage or configuration +- Sharing tips or patterns with other community members ## Issues -In the event you come across a bug in the Azure IPAM tool, please open a [GitHub Issue](https://github.com/Azure/ipam/issues) and someone from the team will respond as soon ASAP. Please include as much detail as you can about what actions were taken leading up to the discovery of the issue, and how (if possible) it can be reproduced. +If you encounter a bug or have a feature request, please open a [GitHub Issue](https://github.com/Azure/ipam/issues) and someone from the team will respond as soon as possible. For bug reports, please include: + +- A description of the problem and any error messages observed +- The steps taken leading up to the issue, and whether it can be reproduced +- Your deployment type (App Service or Function App) and container image variant (Debian or RHEL) + +## Contributing + +Interested in contributing code, documentation, or other improvements? See the [Contributing](/contributing/README) guide for details on setting up a development environment and building container images. diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md index 8e0d3393..2c0e141b 100644 --- a/docs/troubleshooting/README.md +++ b/docs/troubleshooting/README.md @@ -58,19 +58,19 @@ When authenticating to Azure IPAM for the first time, you are presented with a * ### Verify -The role of [Global Administrator](https://learn.microsoft.com/azure/active-directory/roles/permissions-reference#global-administrator) is required to deploy the Azure IPAM solution. This role is needed to [grant admin consent](https://learn.microsoft.com/azure/active-directory/manage-apps/grant-admin-consent?pivots=portal) for the API permissions used by the Azure IPAM [App Registrations](https://learn.microsoft.com/azure/active-directory/develop/app-objects-and-service-principals#application-registration). +The role of [Global Administrator](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator) is required to deploy the Azure IPAM solution. This role is needed to [grant admin consent](https://learn.microsoft.com/entra/identity/enterprise-apps/grant-admin-consent?pivots=portal) for the API permissions used by the Azure IPAM [App Registrations](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals#application-registration). -Navigate to your user in Azure Active Directory and check your current [Role Assignments](https://learn.microsoft.com/azure/active-directory/fundamentals/active-directory-users-assign-role-azure-portal). +Navigate to your user in Microsoft Entra ID and check your current [Role Assignments](https://learn.microsoft.com/entra/identity/role-based-access-control/manage-roles-portal). ![Global Admin Missing](./images/global_admin_role_missing.png) -You can see from the image above that the [Global Administrator](https://learn.microsoft.com/azure/active-directory/roles/permissions-reference#global-administrator) role is not present. +You can see from the image above that the [Global Administrator](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator) role is not present. ### Resolve -Contact your Azure Active Directory Administrator (or equivalent) to request the [Global Administrator](https://learn.microsoft.com/azure/active-directory/roles/permissions-reference#global-administrator) role. +Contact your Microsoft Entra ID Administrator (or equivalent) to request the [Global Administrator](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator) role. -Alternatively, if your organization (like many) has separate groups whom manage Azure Active Directory permissions and Azure infrastructure, you can leverage the two-step deployment method for Azure IPAM where a member of the [Global Administrators](https://learn.microsoft.com/azure/active-directory/roles/permissions-reference#global-administrator) can deploy the required [App Registrations](https://learn.microsoft.com/azure/active-directory/develop/app-objects-and-service-principals#application-registration), then pass the generated [Parameters](https://learn.microsoft.com/azure/azure-resource-manager/templates/parameter-files) file to the Azure Infrastructure team to complete the deployment. +Alternatively, if your organization (like many) has separate groups who manage Microsoft Entra ID permissions and Azure infrastructure, you can leverage the two-step deployment method for Azure IPAM where a member of the [Global Administrators](https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator) can deploy the required [App Registrations](https://learn.microsoft.com/entra/identity-platform/app-objects-and-service-principals#application-registration), then pass the generated [Parameters](https://learn.microsoft.com/azure/azure-resource-manager/templates/parameter-files) file to the Azure Infrastructure team to complete the deployment. Here are the steps from the [Deployment](/deployment/README) section: @@ -92,7 +92,7 @@ You can read more about the requirements for deploying Azure IPAM in the [Prereq - An error in the Application Log for the App Service stating that the *Operation...is not allow through the Azure Cosmos DB endpoint*. -![Cosmos DB Not Allowed Though Endpoint](./images/cosmos_db_not_allowed.png) +![Cosmos DB Not Allowed Through Endpoint](./images/cosmos_db_not_allowed.png) ### Verify diff --git a/docs/update/README.md b/docs/update/README.md index 38a2ec67..3af409e4 100644 --- a/docs/update/README.md +++ b/docs/update/README.md @@ -15,8 +15,10 @@ To successfully update your Azure IPAM deployment, ensure the following prerequi - An Azure Subscription containing your existing Azure IPAM deployment - The following Azure RBAC Roles: - [Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor) or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner) at the Resource Group scope containing your Azure IPAM resources +- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed + - Required to clone the Azure IPAM GitHub repository - [PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) version 7.2.0 or later installed -- [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 2.13.0 or later installed +- [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 10.3.0 or later installed - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) version 2.35.0 or later installed (required only for Private ACR deployments) > **NOTE:** The update script requires access to your existing Azure IPAM resources. Ensure you have the necessary permissions to both read the current configuration and restart/redeploy the App Service. @@ -258,6 +260,7 @@ The update script follows this automated process and will automatically determin - Verifies Azure CLI version (minimum `2.35.0`) and authentication status - Ensures Azure PowerShell and Azure CLI contexts match - Detects container distribution type (Debian/RHEL) by querying application `/api/status` endpoint + - App Service containers only; Function containers use a fixed Dockerfile - Builds new container images using `az acr build` with appropriate Dockerfile - Tags and pushes updated images to private registry (`ipam:latest` or `ipamfunc:latest`) - Restarts the application @@ -313,7 +316,7 @@ After the update completes, verify your Azure IPAM deployment: Verify the application is running and healthy: -![Check App Service Helath](./images/app_service_health.png) +![Check App Service Health](./images/app_service_health.png) ### 2. Version Verification @@ -407,7 +410,7 @@ az account set --subscription "your-subscription-id" 2. Verify ACR permissions and storage capacity 3. Review Azure Container Registry task logs in Azure Portal 4. Ensure the application's `/api/status` endpoint is accessible for container type detection -5. For manual container build instructions, see the [Contributing Guide](/contributing/README.md#manual-container-builds) +5. For manual container build instructions, see the [Contributing Guide](/contributing/README.md#building--updating-production-containers-images-using-a-private-acr) #### ZIP Deploy Failures diff --git a/engine/Dockerfile.deb b/engine/Dockerfile.deb index c6a11f7c..f7181d48 100644 --- a/engine/Dockerfile.deb +++ b/engine/Dockerfile.deb @@ -1,6 +1,9 @@ ARG BASE_IMAGE=python:3.11-slim FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=80 @@ -17,13 +20,18 @@ ENV PIP_ROOT_USER_ACTION=ignore WORKDIR /ipam # Copy Requirements File +ADD ./requirements.txt . ADD ./requirements.lock.txt . # Upgrade PIP RUN pip install --upgrade pip --progress-bar off # Install Dependencies -RUN pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off +RUN if [ "${PROD_BUILD}" = true ]; then \ + pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off; \ +else \ + pip install --no-cache-dir -r ./requirements.txt --progress-bar off; \ +fi # Copy Application Scripts & Sources ADD ./app ./app diff --git a/engine/Dockerfile.func b/engine/Dockerfile.func index 1cabef95..e405dfc0 100644 --- a/engine/Dockerfile.func +++ b/engine/Dockerfile.func @@ -2,18 +2,26 @@ # FROM mcr.microsoft.com/azure-functions/python:3.0-python3.9-appservice FROM mcr.microsoft.com/azure-functions/python:4-python3.11-appservice +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Azure Function Root Directory & Enable Console Logging ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ AzureFunctionsJobHost__Logging__Console__IsEnabled=true # Copy Requirements File +ADD ./requirements.txt . ADD ./requirements.lock.txt . # Upgrade PIP RUN pip install --upgrade pip --progress-bar off # Install Dependencies -RUN pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off +RUN if [ "${PROD_BUILD}" = true ]; then \ + pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off; \ +else \ + pip install --no-cache-dir -r ./requirements.txt --progress-bar off; \ +fi # Copy Application Code to Function App Root Directory COPY . /home/site/wwwroot diff --git a/engine/Dockerfile.rhel b/engine/Dockerfile.rhel index 4bcdcbd1..2e9aa5f8 100644 --- a/engine/Dockerfile.rhel +++ b/engine/Dockerfile.rhel @@ -1,6 +1,9 @@ -ARG BASE_IMAGE=registry.access.redhat.com/ubi8/python-311 +ARG BASE_IMAGE=registry.access.redhat.com/ubi9/python-311 FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=8080 @@ -17,13 +20,18 @@ WORKDIR /ipam USER root # Copy Requirements File +ADD ./requirements.txt /ipam ADD ./requirements.lock.txt /ipam # Upgrade PIP RUN pip install --upgrade pip --progress-bar off # Install Dependencies -RUN pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off +RUN if [ "${PROD_BUILD}" = true ]; then \ + pip install --no-cache-dir -r ./requirements.lock.txt --progress-bar off; \ +else \ + pip install --no-cache-dir -r ./requirements.txt --progress-bar off; \ +fi # Copy Application Scripts & Sources ADD ./app ./app diff --git a/engine/app/models.py b/engine/app/models.py index 61768038..7b52c477 100644 --- a/engine/app/models.py +++ b/engine/app/models.py @@ -245,7 +245,7 @@ def format_tag(cls, data: Any) -> Any: if isinstance(data, dict): if 'id' in data: data["tag"] = { "X-IPAM-RES-ID": data["id"]} - + return data class BlockBasic(BaseModel): @@ -570,9 +570,12 @@ def validate_request(cls, data: Any) -> Any: ################### class ViewSettings(BaseModel): - values: Dict[str, dict] - order: List[str] + # Legacy format (Inovua) + values: Optional[Dict[str, dict]] = None + order: Optional[List[str]] = None sort: Union[dict, None] = None + # AG Grid format + columnState: Optional[List[dict]] = None class User(BaseModel): """DOCSTRING""" diff --git a/engine/app/routers/argquery.py b/engine/app/routers/argquery.py index 2bbdac98..542639a3 100644 --- a/engine/app/routers/argquery.py +++ b/engine/app/routers/argquery.py @@ -204,6 +204,34 @@ | project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes """ +NETWORK_INTERFACE = """ +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where subscriptionId !in~ {} +| where isempty(properties.virtualMachine) +| where isempty(properties.privateEndpoint) +| mv-expand ipconfig = properties.ipConfigurations +| project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, private_ip = ipconfig.properties.privateIPAddress, private_ip_alloc_method = ipconfig.properties.privateIPAllocationMethod, subnet_id = tostring(ipconfig.properties.subnet.id), public_ip_id = tostring(ipconfig.properties.publicIPAddress.id) +| extend subnet_id_lower = tolower(subnet_id) +| extend public_ip_id_lower = tolower(public_ip_id) +| join kind = leftouter ( + resources + | where type =~ 'microsoft.network/virtualnetworks' + | extend subnets = array_length(properties.subnets) + | mv-expand subnet = properties.subnets + | project subnet_id = tostring(subnet.id), subnet_name = subnet.name, vnet_id = id, vnet_name = name + | extend subnet_id_lower = tolower(subnet_id) +) on subnet_id_lower +| join kind = leftouter ( + resources + | where type =~ 'Microsoft.Network/PublicIpAddresses' + | project public_ip_id = tostring(id), public_ip = properties.ipAddress, public_ip_alloc_method = properties.publicIPAllocationMethod + | extend public_ip_id_lower = tolower(public_ip_id) +) on public_ip_id_lower +| extend metadata = pack('kind', 'Network Interface', 'orphaned', true, 'public_ip', public_ip, 'public_ip_id', public_ip_id, 'private_ip_alloc_method', private_ip_alloc_method, 'public_ip_alloc_method', public_ip_alloc_method) +| project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata +""" + PRIVATE_ENDPOINT = """ resources | where type =~ 'microsoft.network/networkinterfaces' @@ -325,7 +353,7 @@ | extend vmss_vm_num = todynamic(replace(@'.*\/virtualMachines/', '', id)) | extend vmss_id = replace(@'/virtualMachines.*', '', id) | extend metadata = pack('kind', 'VM Scale Set', 'vmss_name', vmss_name, 'vmss_vm_num', vmss_vm_num, 'vmss_id', vmss_id) -| project name = strcat(vmss_name, '_', vmss_vm_num), id, private_ips, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata +| project name = strcat(vmss_name, '_', vmss_vm_num), id, private_ips, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ FIREWALL_VNET = """ diff --git a/engine/app/routers/azure.py b/engine/app/routers/azure.py index 7b1d6f66..56af361d 100644 --- a/engine/app/routers/azure.py +++ b/engine/app/routers/azure.py @@ -409,7 +409,7 @@ async def get_vnet( for subnet in vnet['subnets']: subnet['size'] = IPNetwork(subnet['prefix']).size total_used += IPNetwork(subnet['prefix']).size - + vnet['used'] = total_used # Python 3.9+ @@ -422,7 +422,7 @@ async def get_vnet( vnet['parent_block'] = parent_blocks or None updated_vnet_list.append(vnet) - + return updated_vnet_list @router.get( @@ -817,6 +817,22 @@ async def vhub_ep( return results +@router.get( + "/nic", + summary = "Get All Standalone Network Interfaces" +) +async def nic( + authorization: str = Header(None), + admin: str = Depends(get_admin) +): + """ + Get a list of standalone Azure Network Interfaces (not attached to a VM or Private Endpoint). + """ + + results = await arg_query(authorization, admin, argquery.NETWORK_INTERFACE) + + return results + async def multi_helper(func, list, *args): """DOCSTRING""" @@ -847,6 +863,7 @@ async def multi( tasks.append(asyncio.create_task(multi_helper(appgw, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(apim, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(lb, result_list, authorization, admin))) + tasks.append(asyncio.create_task(multi_helper(nic, result_list, authorization, admin))) tasks.append(asyncio.create_task(multi_helper(vhub_ep, result_list, authorization, admin))) await asyncio.gather(*tasks) @@ -912,20 +929,27 @@ async def match_resv_to_vnets(): existing_block_cidrs += target_cidrs - if IPNetwork(resv['cidr']) in IPSet(existing_block_cidrs): + cidr_overlap = IPSet([resv['cidr']]) & IPSet(existing_block_cidrs) + + if cidr_overlap: # print("A vNET with the assigned CIDR has already been associated with the target IP Block.") # logging.info("A vNET with the assigned CIDR has already been associated with the target IP Block.") - resv['status'] = "errCIDRExists" + resv['status'] = "errCIDROverlap" if resv['status'] == "wait": # print("vNET is being added to IP Block...") # logging.info("vNET is being added to IP Block...") - block['vnets'].append( - { - "id": net['id'], - "active": True - } - ) + existing_vnet = next((x for x in block['vnets'] if x['id'].lower() == net['id'].lower()), None) + + if existing_vnet: + existing_vnet['active'] = True + else: + block['vnets'].append( + { + "id": net['id'], + "active": True + } + ) # del block['resv'][index] diff --git a/engine/app/routers/space.py b/engine/app/routers/space.py index 328db197..a64c45bd 100644 --- a/engine/app/routers/space.py +++ b/engine/app/routers/space.py @@ -66,7 +66,7 @@ async def valid_space_name_update(name, space_name, tenant_id): if name.lower() in space_names: raise HTTPException(status_code=400, detail="Updated Space name must be unique.") - + if re.match(SPACE_NAME_REGEX, name): return True @@ -114,7 +114,7 @@ async def valid_block_name_update(name, space_name, block_name, tenant_id): if name.lower() in other_blocks: raise HTTPException(status_code=400, detail="Updated Block name cannot match existing Blocks within the Space.") - + if re.match(BLOCK_NAME_REGEX, name): return True @@ -147,7 +147,7 @@ async def valid_block_cidr_update(cidr, space_name, block_name, tenant_id): else: for vnet in block['vnets']: target_net = next((i for i in net_list if i['id'] == vnet['id']), None) - + if target_net: block_cidrs += target_net['prefixes'] @@ -163,10 +163,10 @@ async def valid_block_cidr_update(cidr, space_name, block_name, tenant_id): if space_set & update_set: raise HTTPException(status_code=400, detail="Updated CIDR cannot overlap other Block CIDRs within the Space.") - + if not block_set.issubset(update_set): return False - + return True async def scrub_block_patch(patch, space_name, block_name, tenant_id): @@ -211,7 +211,7 @@ async def valid_ext_network_name_update(name, space_name, block_name, external_n if name.lower() in other_networks: raise HTTPException(status_code=400, detail="Updated External Network name cannot match existing External Networks within the Block.") - + if re.match(EXTERNAL_NAME_REGEX, name): return True @@ -238,7 +238,7 @@ async def valid_ext_network_cidr_update(cidr, space_name, block_name, external_n if(str(external_network.cidr) != cidr): raise HTTPException(status_code=400, detail="Invalid CIDR value, try '{}' instead.".format(external_network.cidr)) - + if not external_network in IPNetwork(target_block['cidr']): raise HTTPException(status_code=400, detail="Updated External Network CIDR must be contained within the Block CIDR.") @@ -246,7 +246,7 @@ async def valid_ext_network_cidr_update(cidr, space_name, block_name, external_n for vnet in target_block['vnets']: target_net = next((i for i in net_list if i['id'] == vnet['id']), None) - + if target_net: block_cidrs += target_net['prefixes'] @@ -266,10 +266,10 @@ async def valid_ext_network_cidr_update(cidr, space_name, block_name, external_n if block_set & update_set: raise HTTPException(status_code=400, detail="Updated CIDR cannot overlap other Virtual Networks, External Networks, or unfulfilled Reservations within the Block.") - + if not external_set.issubset(update_set): return False - + return True async def scrub_ext_network_patch(patch, space_name, block_name, external_name, tenant_id): @@ -320,7 +320,7 @@ async def valid_ext_subnet_name_update(name, space_name, block_name, external_na if name.lower() in other_subnets: raise HTTPException(status_code=400, detail="Updated External Subnet name cannot match existing External Subnets within the External Network.") - + if re.match(EXTSUBNET_NAME_REGEX, name): return True @@ -347,7 +347,7 @@ async def valid_ext_subnet_cidr_update(cidr, space_name, block_name, external_na if(str(subnet_network.cidr) != cidr): raise HTTPException(status_code=400, detail="Invalid CIDR value, try '{}' instead.".format(subnet_network.cidr)) - + if not subnet_network in IPNetwork(target_external['cidr']): raise HTTPException(status_code=400, detail="Updated External Subnet CIDR must be contained within the External Network CIDR.") @@ -418,7 +418,7 @@ async def valid_ext_endpoint_name_update(name, space_name, block_name, external_ if name.lower() in other_endpoints: raise HTTPException(status_code=400, detail="Updated External Endpoint name cannot match existing External Endpoints within the External Subnet.") - + if re.match(EXTENDPOINT_NAME_REGEX, name): return True @@ -1402,18 +1402,14 @@ async def available_block_nets( net['prefixes'] = valid available_vnets.append(net) - # ADD CHECK TO MAKE SURE VNET ISN'T ASSIGNED TO ANOTHER BLOCK - # assigned_vnets = [''.join(vnet) for space in item['spaces'] for block in space['blocks'] for vnet in block['vnets']] - # unassigned_vnets = list(set(available_vnets) - set(assigned_vnets)) + list(set(assigned_vnets) - set(available_vnets)) - - for space_iter in space_query: - for block_iter in space_iter['blocks']: - for net_iter in block_iter['vnets']: - if space_iter['name'] != space and block_iter['name'] != block: - net_index = next((i for i, item in enumerate(available_vnets) if item['id'] == net_iter['id']), None) - - if net_index: - del available_vnets[net_index] + # for space_iter in space_query: + # for block_iter in space_iter['blocks']: + # for net_iter in block_iter['vnets']: + # if space_iter['name'] != space and block_iter['name'] != block: + # net_index = next((i for i, item in enumerate(available_vnets) if item['id'] == net_iter['id']), None) + # + # if net_index is not None: + # del available_vnets[net_index] if expand: return available_vnets @@ -1519,9 +1515,9 @@ async def create_block_net( if not target_net: raise HTTPException(status_code=400, detail="Invalid network ID.") - target_cidr = next((x for x in target_net['prefixes'] if IPNetwork(x) in IPNetwork(target_block['cidr'])), None) + target_cidrs = [x for x in target_net['prefixes'] if IPNetwork(x) in IPNetwork(target_block['cidr'])] - if not target_cidr: + if not target_cidrs: raise HTTPException(status_code=400, detail="Network CIDR not within block CIDR.") block_net_cidrs = [] @@ -1539,7 +1535,7 @@ async def create_block_net( prefixes = list(filter(lambda x: IPNetwork(x) in IPNetwork(target_block['cidr']), target['prefixes'])) block_net_cidrs += prefixes - cidr_overlap = IPSet(block_net_cidrs) & IPSet([target_cidr]) + cidr_overlap = IPSet(block_net_cidrs) & IPSet(target_cidrs) if cidr_overlap: raise HTTPException(status_code=400, detail="Block already contains network(s) and/or reservation(s) within the CIDR range of target network.") @@ -1611,13 +1607,15 @@ async def update_block_vnets( if not target_net: invalid_nets.append(v) else: - target_cidr = next((x for x in target_net['prefixes'] if IPNetwork(x) in IPNetwork(target_block['cidr'])), None) + target_cidrs = [x for x in target_net['prefixes'] if IPNetwork(x) in IPNetwork(target_block['cidr'])] - if not target_cidr: + if not target_cidrs: outside_block_cidr.append(v) else: - if not net_ipset & IPSet([target_cidr]): - net_ipset.add(target_cidr) + target_ipset = IPSet(target_cidrs) + + if not net_ipset & target_ipset: + net_ipset.update(target_ipset) else: net_overlap = True @@ -1836,7 +1834,7 @@ async def create_external_network( if IPSet([req.cidr]) & resv_set: raise HTTPException(status_code=400, detail="Block contains unfulfilled reservation(s) which overlap the target external network.") - + if IPSet([req.cidr]) & block_set: raise HTTPException(status_code=400, detail="Block contains a virtual network(s) or hub(s) which overlap the target external network.") else: @@ -1846,7 +1844,7 @@ async def create_external_network( raise HTTPException(status_code=500, detail="Network of requested size unavailable in target block.") next_cidr = list(available_network.subnet(req.size))[0] - + new_external = { "name": req.name, "desc": req.desc, @@ -2053,7 +2051,7 @@ async def get_external_subnets( if not target_block: raise HTTPException(status_code=400, detail="Invalid block name.") - + target_external = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None) if not target_external: @@ -2129,7 +2127,7 @@ async def create_external_subnet( next_cidr = IPNetwork(req.cidr) except: raise HTTPException(status_code=400, detail="Invalid CIDR, please ensure CIDR is in valid IPv4 CIDR notation (x.x.x.x/x).") - + if str(next_cidr.cidr) != req.cidr: raise HTTPException(status_code=400, detail="External subnet CIDR invalid, should be {}".format(IPNetwork(req.cidr).cidr)) @@ -2263,7 +2261,7 @@ async def update_ext_subnet( if not external_network: raise HTTPException(status_code=400, detail="Invalid external network name.") - + update_ext_subnet = next((x for x in external_network['subnets'] if x['name'].lower() == subnet.lower()), None) if not update_ext_subnet: @@ -2372,7 +2370,7 @@ async def get_external_subnet_endpoints( if not target_block: raise HTTPException(status_code=400, detail="Invalid block name.") - + target_ext_network = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None) if not target_ext_network: @@ -2636,12 +2634,12 @@ async def delete_external_subnet_endpoints( if not target_block: raise HTTPException(status_code=400, detail="Invalid block name.") - + target_ext_network = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None) if not target_ext_network: raise HTTPException(status_code=400, detail="Invalid external network name.") - + target_ext_subnet = next((x for x in target_ext_network['subnets'] if x['name'].lower() == subnet.lower()), None) if not target_ext_subnet: @@ -2713,7 +2711,7 @@ async def get_external_subnet_endpoint( if not target_ext_subnet: raise HTTPException(status_code=400, detail="Invalid external subnet name.") - + target_ext_endpoint = next((x for x in target_ext_subnet['endpoints'] if x['name'].lower() == endpoint.lower()), None) if not target_ext_endpoint: @@ -2776,7 +2774,7 @@ async def update_ext_endpoint( if not external_network: raise HTTPException(status_code=400, detail="Invalid external network name.") - + external_subnet = next((x for x in external_network['subnets'] if x['name'].lower() == subnet.lower()), None) if not external_subnet: diff --git a/engine/function_app.py b/engine/function_app.py index b055bda4..81cec8d3 100644 --- a/engine/function_app.py +++ b/engine/function_app.py @@ -18,13 +18,18 @@ azureLogger = logging.getLogger('azure') azureLogger.setLevel(logging.ERROR) -app = func.AsgiFunctionApp(app=ipam, http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.AsgiFunctionApp( + app=ipam, + http_auth_level=func.AuthLevel.ANONYMOUS, + function_name="ipam" +) -# @app.function_name(name="ipam-sentinel") -# @app.schedule(schedule="0 * * * * *", arg_name="mytimer", run_on_startup=True, use_monitor=False) -@app.timer_trigger(schedule="0 * * * * *", arg_name="mytimer", run_on_startup=True, use_monitor=False) +timer_blueprint = func.Blueprint() + +@timer_blueprint.function_name("sentinel") +@timer_blueprint.timer_trigger(schedule="0 * * * * *", arg_name="mytimer", run_on_startup=True, use_monitor=False) async def ipam_sentinel(mytimer: func.TimerRequest) -> None: - utc_timestamp = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() + utc_timestamp = datetime.now(timezone.utc).isoformat() logger.info('Azure IPAM Sentinel function was triggered') @@ -38,3 +43,5 @@ async def ipam_sentinel(mytimer: func.TimerRequest) -> None: tb = traceback.format_exc() logger.debug(tb) raise e + +app.register_blueprint(timer_blueprint) diff --git a/engine/requirements.lock.txt b/engine/requirements.lock.txt index dfea6d68..1fdd64ce 100644 --- a/engine/requirements.lock.txt +++ b/engine/requirements.lock.txt @@ -18,7 +18,8 @@ azure-mgmt-core==1.6.0 azure-mgmt-datafactory==9.2.0 azure-mgmt-managementgroups==1.0.0 azure-mgmt-network==29.0.0 -azure-mgmt-resource==24.0.0 +azure-mgmt-resource==25.0.0 +azure-mgmt-resource-subscriptions==1.1.0 azure-mgmt-resourcegraph==8.0.0 certifi==2025.8.3 cffi==2.0.0 diff --git a/engine/requirements.txt b/engine/requirements.txt index 28feea6a..97f1fb01 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,6 +1,5 @@ fastapi[all]>=0.103.0 pydantic[email]>=2.3.0 -msal pyjwt cryptography requests @@ -10,17 +9,16 @@ aiohttp jsonpatch loguru regex -azure-common -azure-identity azure-core +azure-identity +azure-cosmos +azure-functions azure-mgmt-core azure-mgmt-compute azure-mgmt-network azure-mgmt-resource +azure-mgmt-resource-subscriptions azure-mgmt-resourcegraph azure-mgmt-managementgroups azure-mgmt-datafactory -azure-keyvault-secrets -azure-cosmos -azure-functions apscheduler diff --git a/examples/azure-eslz/README.md b/examples/azure-eslz/README.md new file mode 100644 index 00000000..8eb56d3b --- /dev/null +++ b/examples/azure-eslz/README.md @@ -0,0 +1,73 @@ +# Azure Landing Zone with IPAM Integration + +This example demonstrates how to integrate Azure IPAM into a landing zone deployment using Bicep. It automates the full **reserve → deploy → tag** workflow: + +1. **Reserve** — A Bicep deployment script calls the Azure IPAM API to reserve a CIDR block of the requested size. +2. **Deploy** — The reserved CIDR is used as the address space for a new Azure virtual network. +3. **Tag** — The virtual network is tagged with the IPAM reservation ID (`X-IPAM-RES-ID`), which allows Azure IPAM to automatically detect and settle the reservation. + +## Architecture + +The deployment creates two resource groups and the following resources: + +| Resource Group | Resources | +|----------------|-----------| +| `{prefix}-SharedSvcs-rg` | User-Assigned Managed Identity, Log Analytics Workspace, Key Vault | +| `{prefix}-NetworkSvcs-rg` | Deployment Script (IPAM reservation), Virtual Network | + +The managed identity is used by the deployment script to authenticate against the Azure IPAM API. It requires `Contributor` on the network services resource group (a [documented requirement](https://learn.microsoft.com/azure/azure-resource-manager/bicep/deployment-script-bicep) for Bicep deployment scripts). + +## Prerequisites + +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) or [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-azure-powershell) +- [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) v0.21.1 or later +- A running Azure IPAM instance with at least one Space and Block configured +- The managed identity must be granted access to the Azure IPAM Engine API (see [Authentication](../../docs/api/README.md)) + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `landingZonePrefix` | `string` | Yes | Prefix used for all resource names | +| `location` | `string` | No | Azure region (defaults to deployment location) | +| `ipamApiScope` | `string` | Yes | API scope for the IPAM Engine App Registration (e.g., `api://`) | +| `ipamEndpoint` | `string` | Yes | Base URL of your IPAM instance (e.g., `https://myipam.azurewebsites.net`) | +| `ipamSpace` | `string` | Yes | IPAM Space containing the target Block | +| `ipamBlock` | `string` | Yes | IPAM Block to reserve address space from | +| `reservationSize` | `int` | No | CIDR mask size for the reservation (default: `24`) | + +## Deployment + +```bash +az deployment sub create \ + --location eastus \ + --template-file main.bicep \ + --parameters \ + landingZonePrefix='contoso' \ + ipamApiScope='api://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' \ + ipamEndpoint='https://myipam.azurewebsites.net' \ + ipamSpace='CoreNetworking' \ + ipamBlock='EastUS' \ + reservationSize=24 +``` + +## Files + +| File | Description | +|------|-------------| +| `main.bicep` | Subscription-scoped orchestrator | +| `fetchAddressPrefix.bicep` | Deployment script that calls the IPAM reservation API | +| `vnet.bicep` | Virtual network with IPAM reservation tag | +| `managedIdentity.bicep` | User-assigned managed identity | +| `keyVault.bicep` | Key Vault with Azure RBAC authorization | +| `logAnalytics.bicep` | Log Analytics workspace | + +## Standalone Script Examples + +The [`examples/scripts/`](../scripts/) directory contains standalone Bash and PowerShell scripts for calling the Azure IPAM API outside of a Bicep or Terraform deployment. + +## Related Documentation + +- [Automation Patterns](../../docs/automation/README.md) — Common patterns for integrating Azure IPAM into automated workflows +- [API Documentation](../../docs/api/README.md#reservations) — Reservation API endpoints and examples +- [How-To: Reservations](../../docs/how-to/README.md#reservations) — Managing reservations through the UI diff --git a/examples/azure-eslz/fetchAddressPrefix.bicep b/examples/azure-eslz/fetchAddressPrefix.bicep index d8de0567..2bd7a88b 100644 --- a/examples/azure-eslz/fetchAddressPrefix.bicep +++ b/examples/azure-eslz/fetchAddressPrefix.bicep @@ -1,24 +1,47 @@ @description('Deployment Location') param location string = resourceGroup().location -@description('Managed Identity Id') +@description('Managed Identity Resource Id') param managedIdentityId string +@description('Managed Identity Principal Id') +param managedIdentityPrincipalId string + @description('API Scope for Access Token') param ipamApiScope string -@description('API Scope for Access Token') +@description('Azure IPAM Endpoint') param ipamEndpoint string @description('IPAM Space') param ipamSpace string -@description('IPAM Space') +@description('IPAM Block') param ipamBlock string +@description('Reservation size as a CIDR mask (e.g. 24 for a /24)') +param reservationSize int + var ipamUrl = '${ipamEndpoint}/api/spaces/${ipamSpace}/blocks/${ipamBlock}/reservations' -resource fetchNetworkPrefix 'Microsoft.Resources/deploymentScripts@2020-10-01' = { +// The deployment script service requires the identity to have Contributor +// on the resource group where the script runs (for storage and container instance management). +// See: https://learn.microsoft.com/azure/azure-resource-manager/bicep/deployment-script-bicep +var contributorRoleId = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'b24988ac-6180-42a0-ab88-20f7382dd24c' +) + +resource contributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, managedIdentityPrincipalId, contributorRoleId) + properties: { + principalType: 'ServicePrincipal' + roleDefinitionId: contributorRoleId + principalId: managedIdentityPrincipalId + } +} + +resource fetchNetworkPrefix 'Microsoft.Resources/deploymentScripts@2023-08-01' = { name: 'fetchNetworkPrefix' location: location kind: 'AzurePowerShell' @@ -28,8 +51,11 @@ resource fetchNetworkPrefix 'Microsoft.Resources/deploymentScripts@2020-10-01' = '${managedIdentityId}': {} } } + dependsOn: [ + contributorAssignment + ] properties: { - azPowerShellVersion: '7.5' + azPowerShellVersion: '14.0' timeout: 'PT1H' environmentVariables: [ { @@ -40,26 +66,34 @@ resource fetchNetworkPrefix 'Microsoft.Resources/deploymentScripts@2020-10-01' = name: 'IPAM_URL' value: ipamUrl } + { + name: 'RESERVATION_SIZE' + value: string(reservationSize) + } ] scriptContent: ''' - $accessToken = ConvertTo-SecureString (Get-AzAccessToken -ResourceUrl $Env:IPAM_API_SCOPE).Token -AsPlainText - + $accessToken = (Get-AzAccessToken -ResourceUrl $Env:IPAM_API_SCOPE).Token + + if ($accessToken -isnot [System.Security.SecureString]) { + $accessToken = ConvertTo-SecureString $accessToken -AsPlainText -Force + } + $body = @{ - 'size' = 16 + 'size' = [int]$Env:RESERVATION_SIZE } | ConvertTo-Json - + $headers = @{ 'Accept' = 'application/json' 'Content-Type' = 'application/json' } - + $response = Invoke-RestMethod ` - -Method 'Post' ` - -Uri $Env:IPAM_URL ` - -Authentication 'Bearer' ` - -Token $accessToken ` - -Headers $headers ` - -Body $body + -Method 'Post' ` + -Uri $Env:IPAM_URL ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body $DeploymentScriptOutputs = @{} $DeploymentScriptOutputs['cidr'] = $response.cidr diff --git a/examples/azure-eslz/fetchAddressPrefix.ps1 b/examples/azure-eslz/fetchAddressPrefix.ps1 deleted file mode 100644 index ff3d4868..00000000 --- a/examples/azure-eslz/fetchAddressPrefix.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -$accessToken = ConvertTo-SecureString (Get-AzAccessToken -ResourceUrl $Env:IPAM_API_SCOPE).Token -AsPlainText - -$body = @{ - 'size' = 16 -} | ConvertTo-Json - -$headers = @{ - 'Accept' = 'application/json' - 'Content-Type' = 'application/json' -} - -$response = Invoke-RestMethod ` - -Method 'Post' ` - -Uri $Env:IPAM_URL ` - -Authentication 'Bearer' ` - -Token $accessToken ` - -Headers $headers ` - -Body $body - -return $response.cidr -return $response.id \ No newline at end of file diff --git a/examples/azure-eslz/keyVault.bicep b/examples/azure-eslz/keyVault.bicep index abd339bb..c6fe8c4a 100644 --- a/examples/azure-eslz/keyVault.bicep +++ b/examples/azure-eslz/keyVault.bicep @@ -1,63 +1,14 @@ -@description('KeyVault Name') +@description('Key Vault Name') param keyVaultName string @description('Deployment Location') param location string = resourceGroup().location -@description('Managed Identity PrincipalId') -param principalId string - -@description('AzureAD TenantId') -param tenantId string = subscription().tenantId - -resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { name: keyVaultName location: location properties: { - accessPolicies: [ - { - objectId: principalId - tenantId: tenantId - permissions: { - certificates: [ - 'get' - 'list' - 'update' - 'create' - 'import' - 'delete' - 'recover' - 'deleteissuers' - 'managecontacts' - 'manageissuers' - 'getissuers' - 'listissuers' - 'setissuers' - ] - keys: [ - 'get' - 'list' - 'update' - 'create' - 'import' - 'delete' - 'recover' - 'backup' - 'restore' - ] - secrets: [ - 'get' - 'list' - 'set' - 'delete' - 'recover' - 'backup' - 'restore' - ] - } - } - ] - createMode: 'default' + enableRbacAuthorization: true enabledForDeployment: true enabledForDiskEncryption: true enabledForTemplateDeployment: true @@ -71,7 +22,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { name: 'premium' family: 'A' } - tenantId: tenantId + tenantId: subscription().tenantId } } diff --git a/examples/azure-eslz/logAnalytics.bicep b/examples/azure-eslz/logAnalytics.bicep index 1cae04f7..8af250b8 100644 --- a/examples/azure-eslz/logAnalytics.bicep +++ b/examples/azure-eslz/logAnalytics.bicep @@ -5,7 +5,7 @@ param location string = resourceGroup().location param logAnalyticsWorkspaceName string -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' ={ +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: logAnalyticsWorkspaceName location: location properties: { diff --git a/examples/azure-eslz/main.bicep b/examples/azure-eslz/main.bicep index a3a532de..91374904 100644 --- a/examples/azure-eslz/main.bicep +++ b/examples/azure-eslz/main.bicep @@ -1,47 +1,47 @@ -// Global parameters targetScope = 'subscription' @description('Landing Zone Prefix') param landingZonePrefix string -@description('GUID for Resource Naming') -param guid string = newGuid() - @description('Deployment Location') param location string = deployment().location -@description('API Scope for Access Token') +@description('API Scope for Access Token (e.g. api://)') param ipamApiScope string -@description('Azure IPAM Endpoint') +@description('Azure IPAM Endpoint (e.g. https://myipam.azurewebsites.net)') param ipamEndpoint string @description('IPAM Space') param ipamSpace string -@description('IPAM Space') +@description('IPAM Block') param ipamBlock string -// Resource naming variables -var logAnalyticsWorkspaceName = '${landingZonePrefix}-law-${uniqueString(guid)}' -var managedIdentityName = '${landingZonePrefix}-mi-${uniqueString(guid)}' -var networkSvcsResourceGroupName = '${landingZonePrefix}NetworkSvcs-rg-${uniqueString(guid)}' -var sharedSvcsResourceGroupName = '${landingZonePrefix}SharedSvcs-rg-${uniqueString(guid)}' -var vnetName = '${landingZonePrefix}-vnet-${uniqueString(guid)}' +@description('Reservation size as a CIDR mask (e.g. 24 for a /24)') +param reservationSize int = 24 +// Deterministic suffix for resource naming (stable across redeployments) +var uniqueSuffix = uniqueString(subscription().subscriptionId, landingZonePrefix, location) +var logAnalyticsWorkspaceName = '${landingZonePrefix}-law-${uniqueSuffix}' +var managedIdentityName = '${landingZonePrefix}-mi-${uniqueSuffix}' +var keyVaultName = '${landingZonePrefix}-kv-${uniqueSuffix}' +var networkSvcsResourceGroupName = '${landingZonePrefix}-NetworkSvcs-rg' +var sharedSvcsResourceGroupName = '${landingZonePrefix}-SharedSvcs-rg' +var vnetName = '${landingZonePrefix}-vnet-${uniqueSuffix}' -//Resource Groups -resource sharedSvcsResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { +// Resource Groups +resource sharedSvcsResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { location: location name: sharedSvcsResourceGroupName } -resource networkSvcsResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { +resource networkSvcsResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { location: location name: networkSvcsResourceGroupName } -// Managed Identity for Secure Access to KeyVault +// Managed Identity module managedIdentity 'managedIdentity.bicep' = { name: 'managedIdentityModule' scope: sharedSvcsResourceGroup @@ -61,7 +61,17 @@ module logAnalytics 'logAnalytics.bicep' = { } } -// Virtual Network Prefix Script +// Key Vault +module keyVault 'keyVault.bicep' = { + name: 'keyVaultModule' + scope: sharedSvcsResourceGroup + params: { + keyVaultName: keyVaultName + location: location + } +} + +// IPAM Reservation via Deployment Script module fetchAddressPrefix 'fetchAddressPrefix.bicep' = { name: 'fetchAddressPrefixModule' scope: networkSvcsResourceGroup @@ -70,12 +80,14 @@ module fetchAddressPrefix 'fetchAddressPrefix.bicep' = { ipamBlock: ipamBlock ipamEndpoint: ipamEndpoint ipamSpace: ipamSpace + reservationSize: reservationSize location: location managedIdentityId: managedIdentity.outputs.id + managedIdentityPrincipalId: managedIdentity.outputs.principalId } } -// Virtual Network +// Virtual Network (tagged with IPAM reservation ID for automatic settlement) module vnet 'vnet.bicep' = { name: 'vnetModule' scope: networkSvcsResourceGroup diff --git a/examples/azure-eslz/managedIdentity.bicep b/examples/azure-eslz/managedIdentity.bicep index 1c1dca9f..3aba5b69 100644 --- a/examples/azure-eslz/managedIdentity.bicep +++ b/examples/azure-eslz/managedIdentity.bicep @@ -1,29 +1,14 @@ -@description('Contributor Role Assignment GUID') -param contributorAssignmentName string = newGuid() - @description('Deployment Location') param location string = resourceGroup().location @description('Managed Identity Name') param managedIdentityName string -var contributor = 'b24988ac-6180-42a0-ab88-20f7382dd24c' -var contributorId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', contributor) - -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: managedIdentityName location: location } -resource contributorAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - name: contributorAssignmentName - properties: { - principalType: 'ServicePrincipal' - roleDefinitionId: contributorId - principalId: managedIdentity.properties.principalId - } -} - output principalId string = managedIdentity.properties.principalId output clientId string = managedIdentity.properties.clientId output id string = managedIdentity.id diff --git a/examples/azure-eslz/vnet.bicep b/examples/azure-eslz/vnet.bicep index 71a3fcde..ddf03280 100644 --- a/examples/azure-eslz/vnet.bicep +++ b/examples/azure-eslz/vnet.bicep @@ -10,7 +10,7 @@ param vnetAddressPrefix string @description('VNet Name') param vnetName string -resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' = { +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { name: vnetName location: location properties: { @@ -21,6 +21,6 @@ resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' = { } } tags: { - 'ipam-res-id': ipamReservationId + 'X-IPAM-RES-ID': ipamReservationId } } diff --git a/examples/ipam-terraform/README.md b/examples/ipam-terraform/README.md new file mode 100644 index 00000000..051eddcd --- /dev/null +++ b/examples/ipam-terraform/README.md @@ -0,0 +1,84 @@ +# Terraform with IPAM Reservation + +This example demonstrates how to integrate Azure IPAM into a Terraform deployment using the community [Azure IPAM Terraform provider](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs). It automates the full **reserve → deploy → tag** workflow: + +1. **Reserve** — The `azureipam_reservation` resource claims a CIDR block of the requested size from Azure IPAM. +2. **Deploy** — The reserved CIDR is used as the address space for a new Azure virtual network. +3. **Tag** — The reservation's auto-generated tags (including `X-IPAM-RES-ID`) are applied to the virtual network, allowing Azure IPAM to automatically settle the reservation. + +## Prerequisites + +- [Terraform](https://www.terraform.io/downloads) >= 1.4.0 +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (used to obtain an access token) +- An authenticated Azure CLI session (`az login`) +- A running Azure IPAM instance with at least one Space and Block configured + +## Providers + +| Provider | Source | Version | +|----------|--------|---------| +| [azurerm](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) | `hashicorp/azurerm` | `~> 3.0` | +| [azureipam](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs) | `xtratuscloud/azureipam` | `~> 2.0` | + +## Variables + +| Variable | Type | Required | Description | +|----------|------|----------|-------------| +| `location` | `string` | Yes | Azure region for the deployment | +| `rg_name` | `string` | Yes | Resource group name | +| `vnet_name` | `string` | Yes | Virtual network name | +| `vnet_size` | `number` | No | CIDR mask size for the reservation (default: `24`) | +| `ipam_space` | `string` | Yes | IPAM Space containing the target Block | +| `ipam_block` | `string` | Yes | IPAM Block to reserve address space from | +| `ipam_endpoint` | `string` | Yes | Base URL of your IPAM instance (e.g., `https://myipam.azurewebsites.net`) | +| `ipam_api_scope` | `string` | Yes | App ID URI of the IPAM Engine App Registration | + +## Usage + +Copy the example variables file and fill in your values: + +```bash +cp example.tfvars terraform.tfvars +# Edit terraform.tfvars with your values +``` + +Then deploy: + +```bash +terraform init +terraform plan +terraform apply +``` + +## Authentication + +The provider obtains an access token via `az account get-access-token`. This requires an active Azure CLI session. + +> **Security note:** When using the `data "external"` block, the access token is stored in Terraform state. The token is short-lived (~1 hour) which limits its exposure, but if storing credentials in state is a concern for your environment you should use the environment variable approach described below instead. Always ensure your state backend is appropriately secured (e.g. encrypted storage, restricted access). + +For CI/CD pipelines or environments where you prefer to keep credentials out of state, set the `AZUREIPAM_TOKEN` environment variable and remove the `data "external"` block and `token` argument from the provider configuration in `providers.tf`: + +```bash +export AZUREIPAM_TOKEN=$(az account get-access-token --resource "api://" --query accessToken -o tsv) +terraform apply +``` + +## Files + +| File | Description | +|------|-------------| +| `main.tf` | IPAM reservation and Azure resources | +| `providers.tf` | Provider configuration and authentication | +| `variables.tf` | Input variable definitions | +| `outputs.tf` | Output definitions | +| `example.tfvars` | Example variable values | + +## Standalone Script Examples + +The [`examples/scripts/`](../scripts/) directory contains standalone Bash and PowerShell scripts for calling the Azure IPAM API outside of Terraform. These are useful for ad-hoc operations or integration into other tooling. + +## Related Documentation + +- [Automation Patterns](../../docs/automation/README.md) — Common patterns for integrating Azure IPAM into automated workflows +- [API Documentation](../../docs/api/README.md#reservations) — Reservation API endpoints and examples +- [How-To: Reservations](../../docs/how-to/README.md#reservations) — Managing reservations through the UI diff --git a/examples/ipam-terraform/example.tfvars b/examples/ipam-terraform/example.tfvars index 25265804..551d490f 100644 --- a/examples/ipam-terraform/example.tfvars +++ b/examples/ipam-terraform/example.tfvars @@ -1,8 +1,8 @@ -location = "westus3" -rg_name = "rg-network" -vnet_name = "ipam-net" -vnet_size = 24 -ipam_space = "ExampleSpace" -ipam_block = "ExampleBlock" -ipam_api_guid = "443b79fc-5db4-4cee-ade8-17656e7b9c6b" -ipam_app_name = "ipamappsvc" +location = "westus3" +rg_name = "rg-network" +vnet_name = "ipam-net" +vnet_size = 24 +ipam_space = "ExampleSpace" +ipam_block = "ExampleBlock" +ipam_endpoint = "https://ipamappsvc.azurewebsites.net" +ipam_api_scope = "443b79fc-5db4-4cee-ade8-17656e7b9c6b" diff --git a/examples/ipam-terraform/main.tf b/examples/ipam-terraform/main.tf index 509c640a..f045659a 100644 --- a/examples/ipam-terraform/main.tf +++ b/examples/ipam-terraform/main.tf @@ -1,13 +1,8 @@ -# Get new CIDR and TAG from IPAM API -data "external" "ipam-reservation" { - program = ["bash", "${path.root}/scripts/new-ipam-reservation.sh"] - query = { - apiGuid = var.ipam_api_guid - appName = var.ipam_app_name - ipamSpace = var.ipam_space - ipamBlock = var.ipam_block - vnetSize = var.vnet_size - } +# Reserve address space from Azure IPAM +resource "azureipam_reservation" "vnet" { + space = var.ipam_space + blocks = [var.ipam_block] + size = var.vnet_size } # Create a Resource Group @@ -16,22 +11,14 @@ resource "azurerm_resource_group" "rg" { location = var.location } -# Create a Virtual Network within the Resource Group +# Create a Virtual Network using the reserved CIDR. +# The reservation tags include X-IPAM-RES-ID, which allows Azure IPAM +# to automatically detect and settle the reservation. resource "azurerm_virtual_network" "network" { name = var.vnet_name resource_group_name = azurerm_resource_group.rg.name location = azurerm_resource_group.rg.location - address_space = [data.external.ipam-reservation.result.cidr] + address_space = [azureipam_reservation.vnet.cidr] - tags = { - X-IPAM-RES-ID = data.external.ipam-reservation.result.id - } + tags = azureipam_reservation.vnet.tags } - -# Get a Token for the IPAM scope -# data "external" "ipam-token" { -# program = ["bash", "${path.root}/scripts/get-ipam-token.sh"] -# query = { -# apiGuid = var.ipam_api_guid -# } -# } diff --git a/examples/ipam-terraform/outputs.tf b/examples/ipam-terraform/outputs.tf index 1c254b75..0d6f94ae 100644 --- a/examples/ipam-terraform/outputs.tf +++ b/examples/ipam-terraform/outputs.tf @@ -1,16 +1,14 @@ -# Output Virtual Network CIDR -output "new_vnet_cidr" { - value = data.external.ipam-reservation.result.cidr +output "reservation_id" { + description = "The IPAM reservation ID." + value = azureipam_reservation.vnet.id } -# Output TAG to apply to new Virtual Network -output "new_vnet_tag" { - value = { - X-IPAM-RES-ID = data.external.ipam-reservation.result.id - } +output "reserved_cidr" { + description = "The reserved CIDR block assigned to the virtual network." + value = azureipam_reservation.vnet.cidr } -# Output IPAM token -# output "ipam_token" { -# value = data.external.ipam-token.result.token -# } +output "vnet_id" { + description = "The Azure resource ID of the deployed virtual network." + value = azurerm_virtual_network.network.id +} diff --git a/examples/ipam-terraform/providers.tf b/examples/ipam-terraform/providers.tf index 70e9a825..e216e666 100644 --- a/examples/ipam-terraform/providers.tf +++ b/examples/ipam-terraform/providers.tf @@ -1,15 +1,36 @@ terraform { - required_version = ">=1.00" + required_version = ">= 1.4.0" required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=3.0.0" + version = "~> 3.0" + } + azureipam = { + source = "xtratuscloud/azureipam" + version = "~> 2.0" } } } -# Configure the Microsoft Azure Provider +# Obtain an access token for the Azure IPAM Engine API. +# +# NOTE: The token value will be stored in Terraform state. If this is a concern +# for your environment, set the AZUREIPAM_TOKEN environment variable instead and +# remove this block (and the 'token' argument from the azureipam provider below). +# Environment variables are not persisted to state. +data "external" "ipam_token" { + program = ["az", "account", "get-access-token", + "--resource", "api://${var.ipam_api_scope}", + "--query", "{accessToken:accessToken}" + ] +} + provider "azurerm" { features {} } + +provider "azureipam" { + api_url = var.ipam_endpoint + token = data.external.ipam_token.result.accessToken +} diff --git a/examples/ipam-terraform/scripts/get-ipam-token.sh b/examples/ipam-terraform/scripts/get-ipam-token.sh deleted file mode 100644 index d5ac97b0..00000000 --- a/examples/ipam-terraform/scripts/get-ipam-token.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# REQUIREMENTS: -# JQ: sudo apt install jq -# Azure CLI: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash - -# Set execute permissions on script -# chmod +x get-ipam-token.sh - -eval "$(jq -r '@sh "apiGuid=\(.apiGuid)"')" - -token=$(az account get-access-token \ - --resource api://${apiGuid} \ - --query accessToken \ - --output tsv) - -echo $token | jq -r '. | {token: .accessToken}' diff --git a/examples/ipam-terraform/scripts/new-ipam-reservation.sh b/examples/ipam-terraform/scripts/new-ipam-reservation.sh deleted file mode 100755 index 0f9dfddf..00000000 --- a/examples/ipam-terraform/scripts/new-ipam-reservation.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -# REQUIREMENTS: -# JQ: sudo apt install jq -# Azure CLI: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash - -# Set execute permissions on script -# chmod +x new-ipam-reservation.sh - -eval "$(jq -r '@sh "apiGuid=\(.apiGuid) appName=\(.appName) ipamSpace=\(.ipamSpace) ipamBlock=\(.ipamBlock) vnetSize=\(.vnetSize)"')" - -token=$(az account get-access-token \ - --resource api://${apiGuid} \ - --query accessToken \ - --output tsv) - -# Could use this to get just the token as an output instead -# echo $token | jq -r '. | {token: .accessToken}' - -resv=$(curl -X POST https://${appName}.azurewebsites.net/api/spaces/${ipamSpace}/blocks/${ipamBlock}/reservations \ - -H "Authorization: Bearer ${token}" \ - -H "Accept: application/json" \ - -H "Content-Type: application/json" \ - -d "{ \""size\"": ${vnetSize} }" \ - -s) - -echo $resv | jq -r '. | {id: .id, cidr: .cidr}' diff --git a/examples/ipam-terraform/variables.tf b/examples/ipam-terraform/variables.tf index 63d7ac68..786d79ce 100644 --- a/examples/ipam-terraform/variables.tf +++ b/examples/ipam-terraform/variables.tf @@ -1,31 +1,40 @@ variable "location" { + type = string description = "The Azure location to deploy resources to." } variable "rg_name" { + type = string description = "Name for the new Resource Group." } variable "vnet_name" { + type = string description = "Name for the new Virtual Network." } variable "vnet_size" { - description = "Size of the new Virtual Network (Subnet Mask bits)." + type = number + description = "Reservation size as a CIDR mask (e.g. 24 for a /24)." + default = 24 } variable "ipam_space" { - description = "Space in which to create a new CIDR reservation." + type = string + description = "IPAM Space containing the target Block." } variable "ipam_block" { - description = "Block in which to create a new CIDR reservation." + type = string + description = "IPAM Block to reserve address space from." } -variable "ipam_api_guid" { - description = "GUID for the Exposed API on the Engine App Registration." +variable "ipam_endpoint" { + type = string + description = "Base URL of your Azure IPAM instance (e.g. https://myipam.azurewebsites.net)." } -variable "ipam_app_name" { - description = "Name of the App Service or Function running the IPAM Engine." +variable "ipam_api_scope" { + type = string + description = "App ID URI of the IPAM Engine App Registration (e.g. d47d5cd9-b599-4a6a-9d54-254565ff08de)." } diff --git a/examples/scripts/New-IpamReservation.ps1 b/examples/scripts/New-IpamReservation.ps1 new file mode 100644 index 00000000..3cc7deb9 --- /dev/null +++ b/examples/scripts/New-IpamReservation.ps1 @@ -0,0 +1,56 @@ +# ===================================================================== +# Azure IPAM - Create a CIDR Reservation via PowerShell +# ===================================================================== +# +# This script demonstrates how to call the Azure IPAM Reservation API +# using Azure PowerShell. It creates a reservation of the specified +# size in the given Space and Block, and returns the reserved CIDR +# and reservation ID. +# +# The reservation ID should be applied as a tag (X-IPAM-RES-ID) on +# the Azure virtual network created with the reserved CIDR. Azure IPAM +# will automatically detect the tag and settle the reservation. +# +# For more information, see: +# - API Documentation: https://azure.github.io/ipam/#/api/README +# - Automation Patterns: https://azure.github.io/ipam/#/automation/README +# +# Prerequisites: +# - Azure PowerShell (Az module) +# - An authenticated Azure session (Connect-AzAccount) +# ===================================================================== + +# --- Configuration --- +$engineClientId = '' +$ipamEndpoint = 'https://.azurewebsites.net' +$space = 'ExampleSpace' +$block = 'ExampleBlock' +$reservationSize = 24 # CIDR mask size (e.g. 24 = /24) + +# --- Authenticate --- +$accessToken = (Get-AzAccessToken -ResourceUrl "api://$engineClientId").Token + +$headers = @{ + 'Accept' = 'application/json' + 'Content-Type' = 'application/json' +} + +# --- Create Reservation --- +$body = @{ + size = $reservationSize +} | ConvertTo-Json + +$response = Invoke-RestMethod ` + -Method 'Post' ` + -Uri "$ipamEndpoint/api/spaces/$space/blocks/$block/reservations" ` + -Authentication 'Bearer' ` + -Token $accessToken ` + -Headers $headers ` + -Body $body + +# --- Output --- +Write-Output "Reserved CIDR : $($response.cidr)" +Write-Output "Reservation ID: $($response.id)" +Write-Output "" +Write-Output "Apply the following tag to your virtual network:" +Write-Output " X-IPAM-RES-ID = $($response.id)" diff --git a/examples/scripts/README.md b/examples/scripts/README.md new file mode 100644 index 00000000..246d68e5 --- /dev/null +++ b/examples/scripts/README.md @@ -0,0 +1,35 @@ +# Example Scripts + +Standalone scripts for interacting with the Azure IPAM API. These are not used by the [Bicep](../azure-eslz/) or [Terraform](../ipam-terraform/) examples — they are provided as reference implementations for teams that want to integrate Azure IPAM into their own tooling or ad-hoc workflows. + +## Scripts + +| Script | Language | Description | +|--------|----------|-------------| +| `New-IpamReservation.ps1` | PowerShell | Create a CIDR reservation in a given Space and Block | +| `new-ipam-reservation.sh` | Bash | Create a CIDR reservation in a given Space and Block | +| `get-ipam-token.sh` | Bash | Obtain a bearer token for the Azure IPAM Engine API | + +## Prerequisites + +**PowerShell scripts** require: + +- [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-azure-powershell) (Az module) +- An authenticated session (`Connect-AzAccount`) + +> **Note:** As of [Azure PowerShell v14](https://learn.microsoft.com/powershell/azure/release-notes-azureps#1400---may-2025), `Get-AzAccessToken` returns the `.Token` property as a `SecureString`. The PowerShell examples in this folder use the v14+ syntax. If you are using an earlier version, wrap the result with `ConvertTo-SecureString ... -AsPlainText -Force`. + +**Bash scripts** require: + +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [jq](https://jqlang.github.io/jq/) (for JSON parsing) +- An authenticated session (`az login`) + +## Usage + +Each script contains configuration variables at the top that you'll need to edit before running (IPAM endpoint, API scope, Space, Block, etc.). See the comments in each script for details. + +## Related Documentation + +- [API Documentation](../../docs/api/README.md) — Full API reference +- [Automation Patterns](../../docs/automation/README.md) — Common integration strategies diff --git a/examples/scripts/get-ipam-token.sh b/examples/scripts/get-ipam-token.sh new file mode 100755 index 00000000..2852c676 --- /dev/null +++ b/examples/scripts/get-ipam-token.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# ===================================================================== +# Azure IPAM - Get an Access Token via Bash +# ===================================================================== +# +# This script demonstrates how to obtain a bearer token for the Azure +# IPAM Engine API using the Azure CLI. The token can then be used in +# subsequent API calls. +# +# For more information, see: +# - API Documentation: https://azure.github.io/ipam/#/api/README +# - Automation Patterns: https://azure.github.io/ipam/#/automation/README +# +# Prerequisites: +# - Azure CLI (az) +# - An authenticated Azure CLI session (az login) +# ===================================================================== + +set -euo pipefail + +# --- Configuration (edit these) --- +API_SCOPE="" + +# --- Get Token --- +token=$(az account get-access-token \ + --resource "api://${API_SCOPE}" \ + --query accessToken \ + --output tsv) + +echo "${token}" diff --git a/examples/scripts/new-ipam-reservation.sh b/examples/scripts/new-ipam-reservation.sh new file mode 100755 index 00000000..f98179fb --- /dev/null +++ b/examples/scripts/new-ipam-reservation.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# ===================================================================== +# Azure IPAM - Create a CIDR Reservation via Bash +# ===================================================================== +# +# This script demonstrates how to call the Azure IPAM Reservation API +# using the Azure CLI and curl. It creates a reservation of the +# specified size in the given Space and Block, and prints the reserved +# CIDR and reservation ID. +# +# The reservation ID should be applied as a tag (X-IPAM-RES-ID) on +# the Azure virtual network created with the reserved CIDR. Azure IPAM +# will automatically detect the tag and settle the reservation. +# +# For more information, see: +# - API Documentation: https://azure.github.io/ipam/#/api/README +# - Automation Patterns: https://azure.github.io/ipam/#/automation/README +# +# Prerequisites: +# - Azure CLI (az) +# - jq +# - An authenticated Azure CLI session (az login) +# ===================================================================== + +set -euo pipefail + +# --- Configuration (edit these) --- +API_SCOPE="" +IPAM_ENDPOINT="https://.azurewebsites.net" +SPACE="ExampleSpace" +BLOCK="ExampleBlock" +SIZE=24 # CIDR mask size (e.g. 24 = /24) + +# --- Authenticate --- +token=$(az account get-access-token \ + --resource "api://${API_SCOPE}" \ + --query accessToken \ + --output tsv) + +# --- Create Reservation --- +response=$(curl -sS -X POST \ + "${IPAM_ENDPOINT}/api/spaces/${SPACE}/blocks/${BLOCK}/reservations" \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "{\"size\": ${SIZE}}") + +# --- Output --- +echo "${response}" | jq '{id: .id, cidr: .cidr}' + +echo "" +echo "Apply the following tag to your virtual network:" +echo " X-IPAM-RES-ID = $(echo "${response}" | jq -r '.id')" diff --git a/init.sh b/init.sh index 0cf06816..44085f9a 100644 --- a/init.sh +++ b/init.sh @@ -1,7 +1,7 @@ #!/bin/bash PORT=$1 -if [ $WEBSITE_RUN_FROM_PACKAGE = "1" ]; then +if [ "${WEBSITE_RUN_FROM_PACKAGE:-}" = "1" ]; then export PATH=$PATH:$APP_PATH/packages export PYTHONPATH=$PYTHONPATH:$APP_PATH/packages fi diff --git a/lb/Dockerfile.rhel b/lb/Dockerfile.rhel index 529ea07a..527a5303 100644 --- a/lb/Dockerfile.rhel +++ b/lb/Dockerfile.rhel @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=registry.access.redhat.com/ubi8/nginx-124 +ARG BASE_IMAGE=registry.access.redhat.com/ubi9/nginx-126 FROM $BASE_IMAGE # Create File Links diff --git a/migrate/migrate.ps1 b/migrate/migrate.ps1 index 2513c509..870d425b 100644 --- a/migrate/migrate.ps1 +++ b/migrate/migrate.ps1 @@ -139,6 +139,32 @@ $azureCloud = $null # Azure cloud environment (AZURE_PUBLIC, AZURE_US_GOV, et $privateAcr = $false # Flag indicating if private ACR is used vs public registry # Helper Functions +function Get-AccessToken { + Param( + [Parameter(Mandatory = $false)] + [string]$Resource, + [Parameter(Mandatory = $false)] + [switch]$AsPlainText + ) + + $params = @{} + if ($Resource) { $params['ResourceUrl'] = $Resource } + + $token = (Get-AzAccessToken @params).Token + + if ($AsPlainText) { + if ($token -is [System.Security.SecureString]) { + return ConvertFrom-SecureString $token -AsPlainText -Force + } + return $token + } else { + if ($token -isnot [System.Security.SecureString]) { + return ConvertTo-SecureString $token -AsPlainText -Force + } + return $token + } +} + function Get-UserConfirmation { param( [Parameter(Mandatory = $true)] @@ -263,7 +289,7 @@ Function Get-BuildLogs { AZURE_CHINA = "management.chinacloudapi.cn" }; - $accessToken = (Get-AzAccessToken).Token + $accessToken = Get-AccessToken $response = Invoke-RestMethod ` -Method POST ` diff --git a/migrate/modules/keyVault.bicep b/migrate/modules/keyVault.bicep index ce7c226d..4b9d6bf4 100644 --- a/migrate/modules/keyVault.bicep +++ b/migrate/modules/keyVault.bicep @@ -24,6 +24,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' = { name: keyVaultName location: location properties: { + enableSoftDelete: true enablePurgeProtection: true enableRbacAuthorization: true tenantId: tenantId diff --git a/tests/azureipam.tests.ps1 b/tests/azureipam.tests.ps1 index 6809fd5b..960467ef 100644 --- a/tests/azureipam.tests.ps1 +++ b/tests/azureipam.tests.ps1 @@ -3,6 +3,21 @@ BeforeAll { Set-StrictMode -Version Latest + # Full integration suite prerequisites: fail fast with a clear message when required env vars are missing. + $requiredEnvVars = @( + 'IPAM_URL' + 'IPAM_ENGINE_APP_ID' + 'IPAM_RESOURCE_GROUP' + ) + + $missingEnvVars = @($requiredEnvVars | Where-Object { + [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) + }) + + if ($missingEnvVars.Count -gt 0) { + throw "Missing required environment variable(s): $($missingEnvVars -join ', ')." + } + [string]$baseUrl = "$env:IPAM_URL/api" $token = (Get-AzAccessToken -ResourceUrl api://$env:IPAM_ENGINE_APP_ID).Token @@ -70,7 +85,7 @@ BeforeAll { [object[]]$body ) - $jsonBody = $body | ConvertTo-Json + $jsonBody = $body | ConvertTo-Json -AsArray $response = Invoke-RestMethod ` -Method Put ` -Authentication Bearer ` @@ -94,7 +109,7 @@ BeforeAll { [hashtable[]]$body ) - $jsonBody = $body | ConvertTo-Json + $jsonBody = $body | ConvertTo-Json -AsArray $response = Invoke-RestMethod ` -Method Patch ` -Authentication Bearer ` @@ -177,883 +192,1905 @@ BeforeAll { } } -Context 'Spaces' { - # GET /api/spaces - It 'Verify No Spaces Exist' { +# NOTE: This suite intentionally uses ordered, shared-state integration flows. +# - Do not reorder Context/It blocks without updating dependent test data. +# - Later tests depend on resources created earlier (for example: TestSpaceA, TestBlockA, +# associated vNETs, External Networks/Subnets/Endpoints, and Reservations). +# - This tradeoff keeps end-to-end scenarios realistic for API integration validation. +Describe 'Azure IPAM API Integration Tests' -Tag @('Integration') { + Context 'Spaces' { + # GET /api/spaces + It 'Verify No Spaces Exist' { - $spaces, $spacesStatus = Get-ApiResource '/spaces' + $spaces, $spacesStatus = Get-ApiResource '/spaces' - $spaces | Should -Be $null - } + $spacesStatus | Should -Be 200 + $spaces | Should -Be $null + } + + # POST /api/spaces + It 'Create Two Spaces' { + $spaceA = @{ + name = 'TestSpace01' + desc = 'Test Space 1' + } + + $spaceB = @{ + name = 'TestSpace02' + desc = 'Test Space 2' + } + + New-ApiResource '/spaces' $spaceA + New-ApiResource '/spaces' $spaceB - # POST /api/spaces - It 'Create Two Spaces' { - $spaceA = @{ - name = 'TestSpace01' - desc = 'Test Space 1' + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + $spaces.Count | Should -Be 2 + + $spaces.Name | Should -Contain 'TestSpace01' + $spaces.Name | Should -Contain 'TestSpace02' } - $spaceB = @{ - name = 'TestSpace02' - desc = 'Test Space 2' + # DELETE /api/spaces/{space} + It 'Delete a Space' { + Remove-ApiResource '/spaces/TestSpace02' + + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + $spaces.Count | Should -Be 1 + + $spaces.Name | Should -Contain 'TestSpace01' + $spaces.Name | Should -Not -Contain 'TestSpace02' } - New-ApiResource '/spaces' $spaceA - New-ApiResource '/spaces' $spaceB + # PATCH /api/spaces/{space} + It 'Update a Space' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'TestSpaceA' + } + @{ + op = 'replace' + path = '/desc' + value = 'Test Space A' + } + ) - $spaces, $spacesStatus = Get-ApiResource '/spaces' + Update-ApiResource '/spaces/TestSpace01' $update - $spaces.Count | Should -Be 2 - $spaces.Name -contains 'TestSpace01' | Should -Be $true - $spaces.Name -contains 'TestSpace02' | Should -Be $true - } + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + $spaces.Count | Should -Be 1 + + $updatedSpace = $spaces | Where-Object { $_.Name -eq 'TestSpaceA' } | Select-Object -First 1 + + $updatedSpace | Should -Not -BeNullOrEmpty + + $updatedSpace.Name | Should -Be 'TestSpaceA' + $updatedSpace.Desc | Should -Be 'Test Space A' + } + + # GET /api/spaces/{space} + It 'Get A Specific Space' { + + $space, $spaceStatus = Get-ApiResource '/spaces/TestSpaceA' - # DELETE /api/spaces/{space} - It 'Delete a Space' { - Remove-ApiResource '/spaces/TestSpace02' + $spaceStatus | Should -Be 200 - $spaces, $spacesStatus = Get-ApiResource '/spaces' + $space.Name | Should -Be 'TestSpaceA' + $space.Desc | Should -Be 'Test Space A' + } + + # PATCH /api/spaces/{space} + It 'Reject Updating Space with Invalid Name Format' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = '-InvalidSpaceName' + } + ) + + { Update-ApiResource '/spaces/TestSpaceA' $update } | Should -Throw + + $space, $spaceStatus = Get-ApiResource '/spaces/TestSpaceA' + + $spaceStatus | Should -Be 200 + + $space.Name | Should -Be 'TestSpaceA' + } + + # PATCH /api/spaces/{space} + It 'Ignore Unsupported Space Patch Operation Without Mutation' { + $update = @( + @{ + op = 'add' + path = '/name' + value = 'ShouldNotApply' + } + ) + + $space, $spaceStatus = Update-ApiResource '/spaces/TestSpaceA' $update + + $spaceStatus | Should -Be 200 + + $space.Name | Should -Be 'TestSpaceA' + $space.Desc | Should -Be 'Test Space A' + + $currentSpace, $currentSpaceStatus = Get-ApiResource '/spaces/TestSpaceA' + + $currentSpaceStatus | Should -Be 200 - $spaces.Count | Should -Be 1 - $spaces.Name -contains 'TestSpace01' | Should -Be $true - $spaces.Name -contains 'TestSpace02' | Should -Be $false + $currentSpace.Name | Should -Be 'TestSpaceA' + $currentSpace.Desc | Should -Be 'Test Space A' + } } - # PATCH /api/spaces/{space} - It 'Update a Space' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'TestSpaceA' + Context 'Blocks' { + # GET /api/spaces/{space}/blocks + It 'Verify No Blocks Exist' { + + $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + + $blocksStatus | Should -Be 200 + $blocks | Should -Be $null + } + + # POST /api/spaces/{space}/blocks + It 'Create Two Blocks' { + $blockA = @{ + name = 'TestBlock01' + cidr = '10.0.0.0/16' } - @{ - op = 'replace' - path = '/desc' - value = 'Test Space A' + + $blockB = @{ + name = 'TestBlock02' + cidr = '192.168.0.0/24' } - ) - Update-ApiResource '/spaces/TestSpace01' $update + New-ApiResource '/spaces/TestSpaceA/blocks' $blockA + New-ApiResource '/spaces/TestSpaceA/blocks' $blockB - $spaces, $spacesStatus = Get-ApiResource '/spaces' + $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' - $spaces.Count | Should -Be 1 - $spaces[0].Name -eq 'TestSpaceA' | Should -Be $true - $spaces[0].Desc -eq 'Test Space A' | Should -Be $true - } + $blocksStatus | Should -Be 200 + $blocks.Count | Should -Be 2 - # GET /api/spaces/{space} - It 'Get A Specific Space' { + $blocks.Name | Should -Contain 'TestBlock01' + $blocks.Name | Should -Contain 'TestBlock02' + } - $space, $spaceStatus = Get-ApiResource '/spaces/TestSpaceA' + # DELETE /api/spaces/{space} + It 'Reject Deleting a Space with Existing Blocks Without Force' { + { Remove-ApiResource '/spaces/TestSpaceA' } | Should -Throw - $space.Name -eq 'TestSpaceA' | Should -Be $true - $space.Desc -eq 'Test Space A' | Should -Be $true - } -} + $space, $spaceStatus = Get-ApiResource '/spaces/TestSpaceA' -Context 'Blocks' { - # GET /api/spaces/{space}/blocks - It 'Verify No Blocks Exist' { + $spaceStatus | Should -Be 200 - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $space.Name | Should -Be 'TestSpaceA' + } - $blocks | Should -Be $null - } + # DELETE /api/spaces/{space}/blocks/{block} + It 'Delete a Block' { + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlock02' - # POST /api/spaces/{space}/blocks - It 'Create Two Blocks' { - $blockA = @{ - name = 'TestBlock01' - cidr = '10.0.0.0/16' + $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + + $blocksStatus | Should -Be 200 + $blocks.Count | Should -Be 1 + + $blocks.Name | Should -Contain 'TestBlock01' + $blocks.Name | Should -Not -Contain 'TestBlock02' } - $blockB = @{ - name = 'TestBlock02' - cidr = '192.168.0.0/24' + # PATCH /api/spaces/{space}/blocks/{block} + It 'Update a Block' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'TestBlockA' + } + @{ + op = 'replace' + path = '/cidr' + value = '10.1.0.0/16' + } + ) + + Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlock01' $update + + $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + + $blocksStatus | Should -Be 200 + $blocks.Count | Should -Be 1 + + $updatedBlock = $blocks | Where-Object { $_.Name -eq 'TestBlockA' } | Select-Object -First 1 + + $updatedBlock | Should -Not -BeNullOrEmpty + + $updatedBlock.Name | Should -Be 'TestBlockA' + $updatedBlock.Cidr | Should -Be '10.1.0.0/16' } - New-ApiResource '/spaces/TestSpaceA/blocks' $blockA - New-ApiResource '/spaces/TestSpaceA/blocks' $blockB + # GET /api/spaces/{space}/blocks/{block} + It 'Get a Specific Block' { - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' - $blocks.Count | Should -Be 2 - $blocks.Name -contains 'TestBlock01' | Should -Be $true - $blocks.Name -contains 'TestBlock02' | Should -Be $true - } + $blockStatus | Should -Be 200 + + $block.Name | Should -Be 'TestBlockA' + $block.Cidr | Should -Be '10.1.0.0/16' + } + + # PATCH /api/spaces/{space}/blocks/{block} + It 'Reject Updating Block CIDR to Overlap Existing Block CIDR' { + $overlapBlock = @{ + name = 'TestBlockOverlap' + cidr = '100.65.0.0/24' + } + + $newBlock, $newBlockStatus = New-ApiResource '/spaces/TestSpaceA/blocks' $overlapBlock + + $newBlockStatus | Should -Be 201 + + $newBlock.Name | Should -Be 'TestBlockOverlap' + + $update = @( + @{ + op = 'replace' + path = '/cidr' + value = '10.1.0.0/24' + } + ) + + { Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockOverlap' $update } | Should -Throw + + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockOverlap' + + $blockStatus | Should -Be 200 + + $block.Cidr | Should -Be '100.65.0.0/24' + } + + # PATCH /api/spaces/{space}/blocks/{block} + It 'Ignore Unsupported Block Patch Path Without Mutation' { + $update = @( + @{ + op = 'replace' + path = '/nonexistent' + value = 'NoEffect' + } + ) + + $block, $blockStatus = Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' $update + + $blockStatus | Should -Be 200 + + $block.Name | Should -Be 'TestBlockA' + $block.Cidr | Should -Be '10.1.0.0/16' - # DELETE /api/spaces/{space}/blocks/{block} - It 'Delete a Block' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlock02' + $currentBlock, $currentBlockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $currentBlockStatus | Should -Be 200 - $blocks.Count | Should -Be 1 - $blocks.Name -contains 'TestBlock01' | Should -Be $true - $blocks.Name -contains 'TestBlock02' | Should -Be $false + $currentBlock.Name | Should -Be 'TestBlockA' + $currentBlock.Cidr | Should -Be '10.1.0.0/16' + } } - # PATCH /api/spaces/{space}/blocks/{block} - It 'Update a Block' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'TestBlockA' + Context 'Networks' -Tag @('AzureLive') { + # GET /api/spaces/{space}/blocks/{block}/networks + It 'Verify No Networks Exist in Block' { + + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + + $networksStatus | Should -Be 200 + $networks | Should -Be $null + } + + # POST /api/spaces/{space}/blocks/{block}/networks + It 'Add a Virtual Network to Block' -Tag @('LongRunning') { + $script:newNetA = New-AzVirtualNetwork ` + -Name 'TestVNet01' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix '10.1.0.0/24' + + Start-Sleep -Seconds 60 + + $body = @{ + id = $script:newNetA.Id } - @{ - op = 'replace' - path = '/cidr' - value = '10.1.0.0/16' + + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body + + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + + $blockStatus | Should -Be 200 + + ($block.vnets | Select-Object -ExpandProperty id) | Should -Contain $script:newNetA.Id + } + + # DELETE /api/spaces/{space}/blocks/{block} + It 'Reject Deleting Block with Existing Networks Without Force' { + { Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' } | Should -Throw + + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + + $blockStatus | Should -Be 200 + + $block.Name | Should -Be 'TestBlockA' + } + + # POST /api/spaces/{space}/blocks/{block}/networks + It 'Reject vNET Association if Any In-Block Prefix Overlaps Existing Network' -Tag @('LongRunning') { + $script:newNetC = New-AzVirtualNetwork ` + -Name 'TestVNet03' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix @('10.1.3.0/24', '10.1.0.0/24') + + Start-Sleep -Seconds 60 + + $body = @{ + id = $script:newNetC.Id } - ) - Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlock01' $update + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body } | Should -Throw - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - $blocks.Count | Should -Be 1 - $blocks[0].Name -eq 'TestBlockA' | Should -Be $true - $blocks[0].Cidr -eq '10.1.0.0/16' | Should -Be $true - } + $networksStatus | Should -Be 200 - # GET /api/spaces/{space}/blocks/{block} - It 'Get a Specific Block' { + ($networks | Select-Object -ExpandProperty id) | Should -Not -Contain $script:newNetC.Id + } - $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + # PUT /api/spaces/{space}/blocks/{block}/networks + It 'Replace Block Virtual Networks' -Tag @('LongRunning') { + $script:newNetB = New-AzVirtualNetwork ` + -Name 'TestVNet02' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix '10.1.1.0/24' - $block.Name -eq 'TestBlockA' | Should -Be $true - $block.Cidr -eq '10.1.0.0/16' | Should -Be $true - } -} + Start-Sleep -Seconds 60 -Context 'Networks' { - # GET /api/spaces/{space}/blocks/{block}/networks - It 'Verify No Networks Exist in Block' { + $body = @( + $script:newNetA.Id + $script:newNetB.Id + ) - $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body - $networks | Should -Be $null - } + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - # POST /api/spaces/{space}/blocks/{block}/networks - It 'Add a Virtual Network to Block' { - $script:newNetA = New-AzVirtualNetwork ` - -Name 'TestVNet01' ` - -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` - -Location 'westus3' ` - -AddressPrefix '10.1.0.0/24' + $networksStatus | Should -Be 200 - Start-Sleep -Seconds 60 + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetA.Id + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetB.Id + } + + # PUT /api/spaces/{space}/blocks/{block}/networks + It 'Reject Block Network Replacement if Any In-Block Prefix Overlaps Existing Network' -Tag @('LongRunning') { + $script:newNetD = New-AzVirtualNetwork ` + -Name 'TestVNet04' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix @('10.1.4.0/24', '10.1.0.0/24') + + Start-Sleep -Seconds 60 + + $body = @( + $script:newNetA.Id + $script:newNetD.Id + ) + + { Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body } | Should -Throw + + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + + $networksStatus | Should -Be 200 - $body = @{ - id = $script:newNetA.Id + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetA.Id + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetB.Id + ($networks | Select-Object -ExpandProperty id) | Should -Not -Contain $script:newNetD.Id } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body + # POST /api/spaces/{space}/blocks/{block}/networks + It 'Add vNET Association if All In-Block Prefixes Are Available' -Tag @('LongRunning') { + $script:newNetE = New-AzVirtualNetwork ` + -Name 'TestVNet05' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix @('10.1.5.0/24', '10.1.6.0/24') - $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + Start-Sleep -Seconds 60 - $($block.vnets | Select-Object -ExpandProperty id) -contains $script:newNetA.Id | Should -Be $true - } + $body = @{ + id = $script:newNetE.Id + } - # PUT /api/spaces/{space}/blocks/{block}/networks - It 'Replace Block Virtual Networks' { - $script:newNetB = New-AzVirtualNetwork ` - -Name 'TestVNet02' ` - -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` - -Location 'westus3' ` - -AddressPrefix '10.1.1.0/24' + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body - Start-Sleep -Seconds 60 + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - $body = @( - $script:newNetA.Id - $script:newNetB.Id - ) + $networksStatus | Should -Be 200 + + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetE.Id + } + + # DELETE /api/spaces/{space}/blocks/{block}/networks + It 'Delete Block Virtual Network' { + $body = @( + $script:newNetB.Id + ) - Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body - $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetA.Id | Should -Be $true - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetB.Id | Should -Be $true + $networksStatus | Should -Be 200 + + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetA.Id + ($networks | Select-Object -ExpandProperty id) | Should -Not -Contain $script:newNetB.Id + } } - # DELETE /api/spaces/{space}/blocks/{block}/networks - It 'Delete Block Virtual Network' { - $body = @( - $script:newNetB.Id - ) + Context 'External Networks' { + # GET /api/spaces/{space}/blocks/{block}/externals + It 'Verify No External Networks Exist in Block' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 0 + } - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetA.Id | Should -Be $true - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetB.Id | Should -Be $false - } -} + # POST /api/spaces/{space}/blocks/{block}/externals + It 'Add an External Network to Block' { + $script:externalA = @{ + name = "ExternalNetA" + desc = "External Network A" + cidr = "10.1.1.0/24" + } -Context 'External Networks' { - # GET /api/spaces/{space}/blocks/{block}/externals - It 'Verify No External Networks Exist in Block' { + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalA - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $externals.Count | Should -Be 0 - } + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 1 + + $externalA = $externals | Where-Object { $_.Name -eq 'ExternalNetA' } | Select-Object -First 1 + + $externalA | Should -Not -BeNullOrEmpty - # POST /api/spaces/{space}/blocks/{block}/externals - It 'Add an External Network to Block' { - $script:externalA = @{ - name = "ExternalNetA" - desc = "External Network A" - cidr = "10.1.1.0/24" + $externalA.Name | Should -Be "ExternalNetA" + $externalA.Desc | Should -Be "External Network A" + $externalA.Cidr | Should -Be "10.1.1.0/24" } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalA + # POST /api/spaces/{space}/blocks/{block}/externals + It 'Reject External Network CIDR Outside Block Range' { + $outsideExternal = @{ + name = 'ExternalNetOutside' + desc = 'External Outside Block' + cidr = '172.16.1.0/24' + } - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $outsideExternal } | Should -Throw - $externals.Count | Should -Be 1 + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $externals[0].Name -eq "ExternalNetA" | Should -Be $true - $externals[0].Desc -eq "External Network A" | Should -Be $true - $externals[0].Cidr -eq "10.1.1.0/24" | Should -Be $true - } + $externalsStatus | Should -Be 200 + + ($externals | Select-Object -ExpandProperty name) | Should -Not -Contain 'ExternalNetOutside' + } + + # POST /api/spaces/{space}/blocks/{block}/externals + It 'Reject External Network CIDR Overlap with Existing External Network' { + $overlapExternal = @{ + name = 'ExternalNetOverlap' + desc = 'External Overlap Test' + cidr = '10.1.1.128/25' + } + + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $overlapExternal } | Should -Throw - # POST /api/spaces/{space}/blocks/{block}/externals - It 'Add a Second External Network to Block' { - $script:externalB = @{ - name = "ExternalNetB" - desc = "External Network B" - size = 24 + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + + $externalsStatus | Should -Be 200 + + ($externals | Select-Object -ExpandProperty name) | Should -Not -Contain 'ExternalNetOverlap' } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalB + # POST /api/spaces/{space}/blocks/{block}/externals + It 'Add a Second External Network to Block' { + $script:externalB = @{ + name = "ExternalNetB" + desc = "External Network B" + size = 24 + } - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalB - $externals.Count | Should -Be 2 + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $externals[0].Name -eq "ExternalNetA" | Should -Be $true - $externals[0].Desc -eq "External Network A" | Should -Be $true - $externals[0].Cidr -eq "10.1.1.0/24" | Should -Be $true + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 2 - $externals[1].Name -eq "ExternalNetB" | Should -Be $true - $externals[1].Desc -eq "External Network B" | Should -Be $true - $externals[1].Cidr -eq "10.1.2.0/24" | Should -Be $true - } + $externalA = $externals | Where-Object { $_.Name -eq 'ExternalNetA' } | Select-Object -First 1 + $externalB = $externals | Where-Object { $_.Name -eq 'ExternalNetB' } | Select-Object -First 1 - # GET /api/spaces/{space}/blocks/{block}/externals/{external} - It 'Get a Specific External Network' { + $externalA | Should -Not -BeNullOrEmpty + $externalB | Should -Not -BeNullOrEmpty - $external, $externalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' + $externalA.Name | Should -Be "ExternalNetA" + $externalA.Desc | Should -Be "External Network A" + $externalA.Cidr | Should -Be "10.1.1.0/24" - $external.Name -eq "ExternalNetB" | Should -Be $true - $external.Desc -eq "External Network B" | Should -Be $true - $external.Cidr -eq "10.1.2.0/24" | Should -Be $true - } + $externalB.Name | Should -Be "ExternalNetB" + $externalB.Desc | Should -Be "External Network B" + $externalB.Cidr | Should -Be "10.1.2.0/24" + } + + # GET /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Get a Specific External Network' { + + $external, $externalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' + + $externalStatus | Should -Be 200 + + $external.Name | Should -Be "ExternalNetB" + $external.Desc | Should -Be "External Network B" + $external.Cidr | Should -Be "10.1.2.0/24" + } + + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Update an External Network' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'ExternalNetC' + } + @{ + op = 'replace' + path = '/desc' + value = 'External Network C' + } + @{ + op = 'replace' + path = '/cidr' + value = '10.1.3.0/24' + } + ) + + Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' $update + + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 2 + + $externalA = $externals | Where-Object { $_.Name -eq 'ExternalNetA' } | Select-Object -First 1 + $externalC = $externals | Where-Object { $_.Name -eq 'ExternalNetC' } | Select-Object -First 1 + + $externalA | Should -Not -BeNullOrEmpty + $externalC | Should -Not -BeNullOrEmpty + + $externalA.Name | Should -Be "ExternalNetA" + $externalA.Desc | Should -Be "External Network A" + $externalA.Cidr | Should -Be "10.1.1.0/24" + + $externalC.Name | Should -Be "ExternalNetC" + $externalC.Desc | Should -Be "External Network C" + $externalC.Cidr | Should -Be "10.1.3.0/24" + } + + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Ignore Unsupported External Patch Operation Without Mutation' { + $update = @( + @{ + op = 'add' + path = '/name' + value = 'NoEffectExternal' + } + ) + + $external, $externalStatus = Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetC' $update + + $externalStatus | Should -Be 200 + + $external.Name | Should -Be 'ExternalNetC' + $external.Cidr | Should -Be '10.1.3.0/24' + + $currentExternal, $currentExternalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetC' + + $currentExternalStatus | Should -Be 200 + + $currentExternal.Name | Should -Be 'ExternalNetC' + $currentExternal.Cidr | Should -Be '10.1.3.0/24' + } + + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Delete an External Network' { + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetC' - # PATCH /api/spaces/{space}/blocks/{block}/externals/{external} - It 'Update an External Network' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'ExternalNetC' + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 1 + + $externalA = $externals | Where-Object { $_.Name -eq 'ExternalNetA' } | Select-Object -First 1 + + $externalA | Should -Not -BeNullOrEmpty + + $externalA.Name | Should -Be "ExternalNetA" + $externalA.Desc | Should -Be "External Network A" + $externalA.Cidr | Should -Be "10.1.1.0/24" + } + + # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets + It 'Verify No External Subnets Exist in External Network' { + + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + + $subnetsStatus | Should -Be 200 + $subnets.Count | Should -Be 0 + } + + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets + It 'Add an External Subnet to an External Network' { + $script:subnetA = @{ + name = "SubnetA" + desc = "Subnet A" + cidr = "10.1.1.0/26" } - @{ - op = 'replace' - path = '/desc' - value = 'External Network C' + + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $script:subnetA + + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + + $subnetsStatus | Should -Be 200 + $subnets.Count | Should -Be 1 + + $subnetA = $subnets | Where-Object { $_.Name -eq 'SubnetA' } | Select-Object -First 1 + + $subnetA | Should -Not -BeNullOrEmpty + + $subnetA.Name | Should -Be "SubnetA" + $subnetA.Desc | Should -Be "Subnet A" + $subnetA.Cidr | Should -Be "10.1.1.0/26" + } + + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Reject Updating External Network CIDR That Excludes Existing Subnets' { + $update = @( + @{ + op = 'replace' + path = '/cidr' + value = '10.1.2.0/24' + } + ) + + { Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA' $update } | Should -Throw + + $external, $externalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA' + + $externalStatus | Should -Be 200 + + $external.Cidr | Should -Be '10.1.1.0/24' + } + + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets + It 'Reject External Subnet CIDR Outside Parent External Network' { + $outsideSubnet = @{ + name = 'SubnetOutside' + desc = 'Outside Parent External' + cidr = '10.1.2.0/26' } - @{ - op = 'replace' - path = '/cidr' - value = '10.1.3.0/24' + + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $outsideSubnet } | Should -Throw + + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + + $subnetsStatus | Should -Be 200 + + ($subnets | Select-Object -ExpandProperty name) | Should -Not -Contain 'SubnetOutside' + } + + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets + It 'Reject External Subnet CIDR Overlap with Existing Subnet' { + $overlapSubnet = @{ + name = 'SubnetOverlap' + desc = 'Overlapping Subnet' + cidr = '10.1.1.32/27' } - ) - Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' $update + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $overlapSubnet } | Should -Throw - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' - $externals.Count | Should -Be 2 + $subnetsStatus | Should -Be 200 - $externals[0].Name -eq "ExternalNetA" | Should -Be $true - $externals[0].Desc -eq "External Network A" | Should -Be $true - $externals[0].Cidr -eq "10.1.1.0/24" | Should -Be $true + ($subnets | Select-Object -ExpandProperty name) | Should -Not -Contain 'SubnetOverlap' + } - $externals[1].Name -eq "ExternalNetC" | Should -Be $true - $externals[1].Desc -eq "External Network C" | Should -Be $true - $externals[1].Cidr -eq "10.1.3.0/24" | Should -Be $true - } + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets + It 'Add a Second External Subnet to an External Network' { + $script:subnetB = @{ + name = "SubnetB" + desc = "Subnet B" + size = 26 + } - # DELETE /api/spaces/{space}/blocks/{block}/externals/{external} - It 'Delete an External Network' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetC' + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $script:subnetB - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' - $externals.Count | Should -Be 1 + $subnetsStatus | Should -Be 200 + $subnets.Count | Should -Be 2 - $externals[0].Name -eq "ExternalNetA" | Should -Be $true - $externals[0].Desc -eq "External Network A" | Should -Be $true - $externals[0].Cidr -eq "10.1.1.0/24" | Should -Be $true - } + $subnetA = $subnets | Where-Object { $_.Name -eq 'SubnetA' } | Select-Object -First 1 + $subnetB = $subnets | Where-Object { $_.Name -eq 'SubnetB' } | Select-Object -First 1 - # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets - It 'Verify No External Subnets Exist in External Network' { + $subnetA | Should -Not -BeNullOrEmpty + $subnetB | Should -Not -BeNullOrEmpty - $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + $subnetA.Name | Should -Be "SubnetA" + $subnetA.Desc | Should -Be "Subnet A" + $subnetA.Cidr | Should -Be "10.1.1.0/26" - $subnets.Count | Should -Be 0 - } + $subnetB.Name | Should -Be "SubnetB" + $subnetB.Desc | Should -Be "Subnet B" + $subnetB.Cidr | Should -Be "10.1.1.64/26" + } + + # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Get Specific External Subnet' { + + $subnet, $subnetStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' - # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets - It 'Add an External Subnet to an External Network' { - $script:subnetA = @{ - name = "SubnetA" - desc = "Subnet A" - cidr = "10.1.1.0/26" + $subnetStatus | Should -Be 200 + + $subnet.Name | Should -Be "SubnetB" + $subnet.Desc | Should -Be "Subnet B" + $subnet.Cidr | Should -Be "10.1.1.64/26" } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $script:subnetA + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Update an External Subnet' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'SubnetC' + } + @{ + op = 'replace' + path = '/desc' + value = 'Subnet C' + } + @{ + op = 'replace' + path = '/cidr' + value = '10.1.1.128/27' + } + ) + + Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' $update + + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + + $subnetsStatus | Should -Be 200 + $subnets.Count | Should -Be 2 + + $subnetA = $subnets | Where-Object { $_.Name -eq 'SubnetA' } | Select-Object -First 1 + $subnetC = $subnets | Where-Object { $_.Name -eq 'SubnetC' } | Select-Object -First 1 + + $subnetA | Should -Not -BeNullOrEmpty + $subnetC | Should -Not -BeNullOrEmpty + + $subnetA.Name | Should -Be "SubnetA" + $subnetA.Desc | Should -Be "Subnet A" + $subnetA.Cidr | Should -Be "10.1.1.0/26" + + $subnetC.Name | Should -Be "SubnetC" + $subnetC.Desc | Should -Be "Subnet C" + $subnetC.Cidr | Should -Be "10.1.1.128/27" + } - $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Delete an External Subnet' { + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetC' - $subnets.Count | Should -Be 1 + $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' - $subnets[0].Name -eq "SubnetA" | Should -Be $true - $subnets[0].Desc -eq "Subnet A" | Should -Be $true - $subnets[0].Cidr -eq "10.1.1.0/26" | Should -Be $true - } + $subnetsStatus | Should -Be 200 + $subnets.Count | Should -Be 1 - # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets - It 'Add a Second External Subnet to an External Network' { - $script:subnetB = @{ - name = "SubnetB" - desc = "Subnet B" - size = 26 + $subnetA = $subnets | Where-Object { $_.Name -eq 'SubnetA' } | Select-Object -First 1 + + $subnetA | Should -Not -BeNullOrEmpty + + $subnetA.Name | Should -Be "SubnetA" + $subnetA.Desc | Should -Be "Subnet A" + $subnetA.Cidr | Should -Be "10.1.1.0/26" } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' $script:subnetB + # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Verify No External Endpoints Exist in External Subnet' { + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 0 + } - $subnets.Count | Should -Be 2 + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Add an External Endpoint to an External Subnet' { + $script:endpointA = @{ + name = "EndpointA" + desc = "Endpoint A" + ip = "10.1.1.4" + } - $subnets[0].Name -eq "SubnetA" | Should -Be $true - $subnets[0].Desc -eq "Subnet A" | Should -Be $true - $subnets[0].Cidr -eq "10.1.1.0/26" | Should -Be $true + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $script:endpointA - $subnets[1].Name -eq "SubnetB" | Should -Be $true - $subnets[1].Desc -eq "Subnet B" | Should -Be $true - $subnets[1].Cidr -eq "10.1.1.64/26" | Should -Be $true - } + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} - It 'Get Specific External Subnet' { + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 1 - $subnet, $subnetStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 - $subnet.Name -eq "SubnetB" | Should -Be $true - $subnet.Desc -eq "Subnet B" | Should -Be $true - $subnet.Cidr -eq "10.1.1.64/26" | Should -Be $true - } + $endpointA | Should -Not -BeNullOrEmpty + + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + } - # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} - It 'Update an External Subnet' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'SubnetC' + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Add a Second External Endpoint to an External Subnet' { + $script:endpointB = @{ + name = "EndpointB" + desc = "Endpoint B" + ip = $null } - @{ - op = 'replace' - path = '/desc' - value = 'Subnet C' + + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $script:endpointB + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 + $endpointB = $endpoints | Where-Object { $_.Name -eq 'EndpointB' } | Select-Object -First 1 + + $endpointA | Should -Not -BeNullOrEmpty + $endpointB | Should -Not -BeNullOrEmpty + + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + + $endpointB.Name | Should -Be "EndpointB" + $endpointB.Desc | Should -Be "Endpoint B" + $endpointB.IP | Should -Be "10.1.1.1" + } + + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Creating External Endpoint with IP Outside Subnet CIDR' { + $outsideEndpoint = @{ + name = 'EndpointOutsideSubnet' + desc = 'Endpoint Outside Subnet' + ip = '10.1.2.10' } - @{ - op = 'replace' - path = '/cidr' - value = '10.1.1.128/27' + + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $outsideEndpoint } | Should -Throw + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + ($endpoints | Select-Object -ExpandProperty name) | Should -Not -Contain 'EndpointOutsideSubnet' + } + + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Creating External Endpoint with Duplicate IP Address' { + $duplicateIpEndpoint = @{ + name = 'EndpointDuplicateIP' + desc = 'Endpoint Duplicate IP' + ip = '10.1.1.4' } - ) - Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' $update + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $duplicateIpEndpoint } | Should -Throw - $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - $subnets.Count | Should -Be 2 + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 - $subnets[0].Name -eq "SubnetA" | Should -Be $true - $subnets[0].Desc -eq "Subnet A" | Should -Be $true - $subnets[0].Cidr -eq "10.1.1.0/26" | Should -Be $true + ($endpoints | Select-Object -ExpandProperty name) | Should -Not -Contain 'EndpointDuplicateIP' + } - $subnets[1].Name -eq "SubnetC" | Should -Be $true - $subnets[1].Desc -eq "Subnet C" | Should -Be $true - $subnets[1].Cidr -eq "10.1.1.128/27" | Should -Be $true - } + # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Creating External Endpoint with Duplicate Name' { + $duplicateNameEndpoint = @{ + name = 'EndpointA' + desc = 'Endpoint Duplicate Name' + ip = '10.1.1.6' + } - # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} - It 'Delete an External Subnet' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetC' + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $duplicateNameEndpoint } | Should -Throw - $subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets' + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - $subnets.Count | Should -Be 1 + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + } - $subnets[0].Name -eq "SubnetA" | Should -Be $true - $subnets[0].Desc -eq "Subnet A" | Should -Be $true - $subnets[0].Cidr -eq "10.1.1.0/26" | Should -Be $true - } + # PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Replace External Endpoints in an External Subnet' { + $script:endpointC = @{ + name = "EndpointC" + desc = "Endpoint C" + ip = "10.1.1.5" + } + + $script:endpointD = @{ + name = "EndpointD" + desc = "Endpoint D" + ip = $null + } - # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints - It 'Verify No External Endpoints Exist in External Subnet' { + $body = @( + $script:endpointA + $script:endpointB + $script:endpointC + $script:endpointD + ) - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body - $endpoints.Count | Should -Be 0 - } + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 4 - # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints - It 'Add an External Endpoint to an External Subnet' { - $script:endpointA = @{ - name = "EndpointA" - desc = "Endpoint A" - ip = "10.1.1.4" + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 + $endpointB = $endpoints | Where-Object { $_.Name -eq 'EndpointB' } | Select-Object -First 1 + $endpointC = $endpoints | Where-Object { $_.Name -eq 'EndpointC' } | Select-Object -First 1 + $endpointD = $endpoints | Where-Object { $_.Name -eq 'EndpointD' } | Select-Object -First 1 + + $endpointA | Should -Not -BeNullOrEmpty + $endpointB | Should -Not -BeNullOrEmpty + $endpointC | Should -Not -BeNullOrEmpty + $endpointD | Should -Not -BeNullOrEmpty + + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + + $endpointB.Name | Should -Be "EndpointB" + $endpointB.Desc | Should -Be "Endpoint B" + $endpointB.IP | Should -Be "10.1.1.1" + + $endpointC.Name | Should -Be "EndpointC" + $endpointC.Desc | Should -Be "Endpoint C" + $endpointC.IP | Should -Be "10.1.1.5" + + $endpointD.Name | Should -Be "EndpointD" + $endpointD.Desc | Should -Be "Endpoint D" + $endpointD.IP | Should -Be "10.1.1.2" } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $script:endpointA + # DELETE /api/spaces/{space}/blocks/{block}/externals/ExternalNetA/subnets/SubnetA/endpoints + It 'Delete External Endpoints' { + $body = @( + $script:endpointC.name + $script:endpointD.name + ) - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body - $endpoints.Count | Should -Be 1 + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true - } + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 + $endpointB = $endpoints | Where-Object { $_.Name -eq 'EndpointB' } | Select-Object -First 1 - # POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints - It 'Add a Second External Endpoint to an External Subnet' { - $script:endpointB = @{ - name = "EndpointB" - desc = "Endpoint B" - ip = $null + $endpointA | Should -Not -BeNullOrEmpty + $endpointB | Should -Not -BeNullOrEmpty + + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + + $endpointB.Name | Should -Be "EndpointB" + $endpointB.Desc | Should -Be "Endpoint B" + $endpointB.IP | Should -Be "10.1.1.1" } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $script:endpointB + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Reject Updating External Subnet CIDR That Excludes Existing Endpoints' { + $update = @( + @{ + op = 'replace' + path = '/cidr' + value = '10.1.1.64/26' + } + ) - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + { Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA' $update } | Should -Throw - $endpoints.Count | Should -Be 2 + $subnet, $subnetStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA' - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true + $subnetStatus | Should -Be 200 - $endpoints[1].Name -eq "EndpointB" | Should -Be $true - $endpoints[1].Desc -eq "Endpoint B" | Should -Be $true - $endpoints[1].IP -eq "10.1.1.1" | Should -Be $true - } + $subnet.Cidr | Should -Be '10.1.1.0/26' + } - # PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints - It 'Replace External Endpoints in an External Subnet' { - $script:endpointC = @{ - name = "EndpointC" - desc = "Endpoint C" - ip = "10.1.1.5" + # PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Replacing Endpoints with Duplicate Names' { + $body = @( + @{ + name = 'EndpointA' + desc = 'Endpoint A Duplicate Test 1' + ip = '10.1.1.4' + } + @{ + name = 'EndpointA' + desc = 'Endpoint A Duplicate Test 2' + ip = '10.1.1.6' + } + ) + + { Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body } | Should -Throw + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointA' + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointB' } - $script:endpointD = @{ - name = "EndpointD" - desc = "Endpoint D" - ip = $null + # PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Replacing Endpoints with Overlapping IP Addresses' { + $body = @( + @{ + name = 'EndpointOverlapA' + desc = 'Endpoint Overlap A' + ip = '10.1.1.7' + } + @{ + name = 'EndpointOverlapB' + desc = 'Endpoint Overlap B' + ip = '10.1.1.7' + } + ) + + { Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body } | Should -Throw + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointA' + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointB' } - $body = @( - $script:endpointA - $script:endpointB - $script:endpointC - $script:endpointD - ) + # PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints + It 'Reject Replacing Endpoints with IP Outside Subnet CIDR' { + $body = @( + @{ + name = 'EndpointOutsideA' + desc = 'Endpoint Outside A' + ip = '10.1.1.8' + } + @{ + name = 'EndpointOutsideB' + desc = 'Endpoint Outside B' + ip = '10.1.2.8' + } + ) + + { Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body } | Should -Throw + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointA' + ($endpoints | Select-Object -ExpandProperty Name) | Should -Contain 'EndpointB' + } - Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body + # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} + It 'Get a Specific External Endpoint' { - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + $endpoint, $endpointStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointA' - $endpoints.Count | Should -Be 4 + $endpointStatus | Should -Be 200 - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true + $endpoint.Name | Should -Be "EndpointA" + $endpoint.Desc | Should -Be "Endpoint A" + $endpoint.IP | Should -Be "10.1.1.4" + } - $endpoints[1].Name -eq "EndpointB" | Should -Be $true - $endpoints[1].Desc -eq "Endpoint B" | Should -Be $true - $endpoints[1].IP -eq "10.1.1.1" | Should -Be $true + # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} + It 'Update an External Endpoint' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'EndpointC' + } + @{ + op = 'replace' + path = '/desc' + value = 'Endpoint C' + } + @{ + op = 'replace' + path = '/ip' + value = '10.1.1.10' + } + ) + + Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointB' $update + + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 2 + + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 + $endpointC = $endpoints | Where-Object { $_.Name -eq 'EndpointC' } | Select-Object -First 1 + + $endpointA | Should -Not -BeNullOrEmpty + $endpointC | Should -Not -BeNullOrEmpty + + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + + $endpointC.Name | Should -Be "EndpointC" + $endpointC.Desc | Should -Be "Endpoint C" + $endpointC.IP | Should -Be "10.1.1.10" + } - $endpoints[2].Name -eq "EndpointC" | Should -Be $true - $endpoints[2].Desc -eq "Endpoint C" | Should -Be $true - $endpoints[2].IP -eq "10.1.1.5" | Should -Be $true + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} + It 'Delete an External Endpoint' { + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointC' - $endpoints[3].Name -eq "EndpointD" | Should -Be $true - $endpoints[3].Desc -eq "Endpoint D" | Should -Be $true - $endpoints[3].IP -eq "10.1.1.2" | Should -Be $true - } + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - # DELETE /api/spaces/{space}/blocks/{block}/externals/ExternalNetA/subnets/SubnetA/endpoints - It 'Delete External Endpoints' { - $body = @( - $script:endpointC.name - $script:endpointD.name - ) + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 1 - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $body + $endpointA = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + $endpointA | Should -Not -BeNullOrEmpty - $endpoints.Count | Should -Be 2 + $endpointA.Name | Should -Be "EndpointA" + $endpointA.Desc | Should -Be "Endpoint A" + $endpointA.IP | Should -Be "10.1.1.4" + } - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Reject Deleting External Network with Subnets Without Force' { + $script:forceDeleteExternal = @{ + name = 'ExternalForceDeleteA' + desc = 'External Force Delete A' + cidr = '10.1.240.0/24' + } - $endpoints[1].Name -eq "EndpointB" | Should -Be $true - $endpoints[1].Desc -eq "Endpoint B" | Should -Be $true - $endpoints[1].IP -eq "10.1.1.1" | Should -Be $true - } + $script:forceDeleteSubnet = @{ + name = 'SubnetForceDeleteA' + desc = 'Subnet Force Delete A' + cidr = '10.1.240.0/26' + } - # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} - It 'Get a Specific External Endpoint' { + $newExternal, $newExternalStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:forceDeleteExternal + $newSubnet, $newSubnetStatus = New-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteExternal.name)/subnets" $script:forceDeleteSubnet - $endpoint, $endpointStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointA' + $newExternalStatus | Should -Be 201 + $newSubnetStatus | Should -Be 201 - $endpoint.Name | Should -Be "EndpointA" - $endpoint.Desc | Should -Be "Endpoint A" - $endpoint.IP | Should -Be "10.1.1.4" - } + { Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteExternal.name)" } | Should -Throw + + $external, $externalStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteExternal.name)" + + $externalStatus | Should -Be 200 + + $external.Name | Should -Be $script:forceDeleteExternal.name + $external.Subnets.Count | Should -Be 1 + } - # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} - It 'Update an External Endpoint' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'EndpointC' + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external} + It 'Delete External Network with Subnets When Force Is True' { + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteExternal.name)?force=true" + + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + + $externalsStatus | Should -Be 200 + + ($externals | Select-Object -ExpandProperty name) | Should -Not -Contain $script:forceDeleteExternal.name + } + + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Reject Deleting External Subnet with Endpoints Without Force' { + $script:forceDeleteEndpointExternal = @{ + name = 'ExternalForceDeleteB' + desc = 'External Force Delete B' + cidr = '10.1.241.0/24' } - @{ - op = 'replace' - path = '/desc' - value = 'Endpoint C' + + $script:forceDeleteEndpointSubnet = @{ + name = 'SubnetForceDeleteB' + desc = 'Subnet Force Delete B' + cidr = '10.1.241.0/26' } - @{ - op = 'replace' - path = '/ip' - value = '10.1.1.10' + + $script:forceDeleteEndpoint = @{ + name = 'EndpointForceDeleteB' + desc = 'Endpoint Force Delete B' + ip = $null } - ) - Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointB' $update + $newExternal, $newExternalStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:forceDeleteEndpointExternal + $newSubnet, $newSubnetStatus = New-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets" $script:forceDeleteEndpointSubnet + $newEndpoint, $newEndpointStatus = New-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets/$($script:forceDeleteEndpointSubnet.name)/endpoints" $script:forceDeleteEndpoint - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + $newExternalStatus | Should -Be 201 + $newSubnetStatus | Should -Be 201 + $newEndpointStatus | Should -Be 200 - $endpoints.Count | Should -Be 2 + { Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets/$($script:forceDeleteEndpointSubnet.name)" } | Should -Throw - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true + $subnet, $subnetStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets/$($script:forceDeleteEndpointSubnet.name)" - $endpoints[1].Name -eq "EndpointC" | Should -Be $true - $endpoints[1].Desc -eq "Endpoint C" | Should -Be $true - $endpoints[1].IP -eq "10.1.1.10" | Should -Be $true - } + $subnetStatus | Should -Be 200 + + $subnet.Name | Should -Be $script:forceDeleteEndpointSubnet.name + ($subnet.Endpoints | Select-Object -ExpandProperty name) | Should -Contain $script:forceDeleteEndpoint.name + } - # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} - It 'Delete an External Endpoint' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointC' + # DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} + It 'Delete External Subnet with Endpoints When Force Is True' { + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets/$($script:forceDeleteEndpointSubnet.name)?force=true" - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + $subnets, $subnetsStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets" - $endpoints.Count | Should -Be 1 + $subnetsStatus | Should -Be 200 - $endpoints[0].Name -eq "EndpointA" | Should -Be $true - $endpoints[0].Desc -eq "Endpoint A" | Should -Be $true - $endpoints[0].IP -eq "10.1.1.4" | Should -Be $true - } -} + if($subnets) { + ($subnets | Select-Object -ExpandProperty name) | Should -Not -Contain $script:forceDeleteEndpointSubnet.name + } + else { + $subnets.Count | Should -Be 0 + } -Context 'Reservations' { - # GET /api/spaces/{space}/blocks/{block}/reservations - It 'Verify No Reservations Exist in Block' { + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)" - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $reservations | Should -Be $null + $externalsStatus | Should -Be 200 + + ($externals | Select-Object -ExpandProperty name) | Should -Not -Contain $script:forceDeleteEndpointExternal.name + } } - # POST /api/spaces/{space}/blocks/{block}/reservations - It 'Create Two Block Reservations' { - $bodyA = @{ - size = 24 - desc = "Test Reservation A" + Context 'Reservations' -Tag @('AzureLive') { + # GET /api/spaces/{space}/blocks/{block}/reservations + It 'Verify No Reservations Exist in Block' { + + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' + + $reservationsStatus | Should -Be 200 + + $reservations | Should -Be $null } - $bodyB = @{ - size = 24 - desc = "Test Reservation B" + # POST /api/spaces/{space}/reservations + It 'Reject Space Reservation with Invalid Block List' { + $body = @{ + blocks = @('InvalidBlockName') + size = 26 + desc = 'Invalid Space Reservation Test' + } + + { New-ApiResource '/spaces/TestSpaceA/reservations' $body } | Should -Throw } - $bodyC = @{ - size = 24 - desc = "Test Reservation C" + # POST /api/spaces/{space}/reservations + It 'Create Space Reservation from Block List' { + $spaceReservationBlock = @{ + name = 'TestBlockSpaceResv' + cidr = '100.64.0.0/24' + } + + $newBlock, $newBlockStatus = New-ApiResource '/spaces/TestSpaceA/blocks' $spaceReservationBlock + + $body = @{ + blocks = @('TestBlockSpaceResv') + size = 26 + desc = 'Test Space Reservation A' + } + + $script:spaceReservationA, $spaceReservationAStatus = New-ApiResource '/spaces/TestSpaceA/reservations' $body + + $newBlockStatus | Should -Be 201 + $spaceReservationAStatus | Should -Be 201 + + $newBlock.Name | Should -Be 'TestBlockSpaceResv' + $newBlock.Cidr | Should -Be '100.64.0.0/24' + + $script:spaceReservationA.Space | Should -Be 'TestSpaceA' + $script:spaceReservationA.Block | Should -Be 'TestBlockSpaceResv' + $script:spaceReservationA.Desc | Should -Be 'Test Space Reservation A' + $script:spaceReservationA.Cidr | Should -Be '100.64.0.0/26' + $script:spaceReservationA.SettledOn | Should -Be $null + $script:spaceReservationA.Status | Should -Be 'wait' } - $script:reservationA, $reservationAStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyA - $script:reservationB, $reservationBStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyB - $script:reservationC, $reservationCStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyC + # GET /api/spaces/{space}/reservations + It 'List Unsettled Space Reservations' { - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' + $spaceReservations, $spaceReservationsStatus = Get-ApiResource '/spaces/TestSpaceA/reservations' - $reservations.Count | Should -Be 3 + $spaceReservationsStatus | Should -Be 200 - $reservations[0].Space -eq "TestSpaceA" | Should -Be $true - $reservations[0].Block -eq "TestBlockA" | Should -Be $true - $reservations[0].Desc -eq "Test Reservation A" | Should -Be $true - $reservations[0].Cidr -eq "10.1.2.0/24" | Should -Be $true - $reservations[0].SettledOn -eq $null | Should -Be $true + ($spaceReservations | Select-Object -ExpandProperty id) | Should -Contain $script:spaceReservationA.Id + } - $reservations[1].Space -eq "TestSpaceA" | Should -Be $true - $reservations[1].Block -eq "TestBlockA" | Should -Be $true - $reservations[1].Desc -eq "Test Reservation B" | Should -Be $true - $reservations[1].Cidr -eq "10.1.3.0/24" | Should -Be $true - $reservations[1].SettledOn -eq $null | Should -Be $true + # DELETE /api/spaces/{space}/blocks/{block}/reservations/{reservationId} + It 'Cancel Space Reservation' { - $reservations[2].Space -eq "TestSpaceA" | Should -Be $true - $reservations[2].Block -eq "TestBlockA" | Should -Be $true - $reservations[2].Desc -eq "Test Reservation C" | Should -Be $true - $reservations[2].Cidr -eq "10.1.4.0/24" | Should -Be $true - $reservations[2].SettledOn -eq $null | Should -Be $true - } + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockSpaceResv/reservations/$($script:spaceReservationA.Id)" - # Create an Azure Virtual Network w/ Reservation ID Tag and Verify it's Automatically Imported into IPAM - It 'Import Virtual Network via Reservation ID' { - $script:newNetC = New-AzVirtualNetwork ` - -Name 'TestVNet03' ` - -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` - -Location 'westus3' ` - -AddressPrefix $script:reservationA.Cidr ` - -Tag @{ "X-IPAM-RES-ID" = $script:reservationA.Id } + $spaceReservations, $spaceReservationsStatus = Get-ApiResource '/spaces/TestSpaceA/reservations' - Start-Sleep -Seconds 180 + $spaceReservationsStatus | Should -Be 200 - $query = @{ - settled = $true + if($spaceReservations) { + ($spaceReservations | Select-Object -ExpandProperty id) | Should -Not -Contain $script:spaceReservationA.Id + } + else { + $spaceReservations | Should -Be $null + } } - $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query + # GET /api/spaces/{space}/reservations + It 'List Settled Space Reservations' { + $query = @{ + settled = $true + } - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetA.Id | Should -Be $true - $($networks | Select-Object -ExpandProperty id) -contains $script:newNetC.Id | Should -Be $true + $spaceReservations, $spaceReservationsStatus = Get-ApiResource '/spaces/TestSpaceA/reservations' $query - $reservations.Count | Should -Be 3 + $spaceReservationsStatus | Should -Be 200 - $reservations[0].SettledOn -eq $null | Should -Be $false - $reservations[0].Status -eq "fulfilled" | Should -Be $true - $reservations[1].SettledOn -eq $null | Should -Be $true - $reservations[1].Status -eq "wait" | Should -Be $true - $reservations[2].SettledOn -eq $null | Should -Be $true - $reservations[2].Status -eq "wait" | Should -Be $true - } + $targetReservation = $spaceReservations | Where-Object { $_.Id -eq $script:spaceReservationA.Id } | Select-Object -First 1 - # DELETE /api/spaces/{space}/blocks/{block}/reservations - It 'Delete Reservations' { - $body = @( - $script:reservationB.Id - ) + $targetReservation | Should -Not -BeNullOrEmpty - $query = @{ - settled = $true + $targetReservation.Status | Should -Be 'cancelledByUser' + $targetReservation.SettledOn | Should -Not -Be $null } - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $body + # POST /api/spaces/{space}/blocks/{block}/reservations + It 'Reject Specific Block Reservation CIDR Outside Block Availability' { + $body = @{ + cidr = '10.2.0.0/24' + desc = 'Outside Block CIDR' + } - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $body } | Should -Throw - $reservations.Count | Should -Be 3 + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' - $reservations[0].SettledOn -eq $null | Should -Be $false - $reservations[0].Status -eq "fulfilled" | Should -Be $true - $reservations[1].SettledOn -eq $null | Should -Be $false - $reservations[1].Status -eq "cancelledByUser" | Should -Be $true - $reservations[2].SettledOn -eq $null | Should -Be $true - $reservations[2].Status -eq "wait" | Should -Be $true - } + $reservationsStatus | Should -Be 200 - # GET /api/spaces/{space}/blocks/{block}/reservations/{reservationId} - It 'Get a Specific Reservation' { + if($reservations) { + ($reservations | Select-Object -ExpandProperty cidr) | Should -Not -Contain '10.2.0.0/24' + } + else { + $reservations | Should -Be $null + } + } - $reservation, $reservationStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationC.Id)" + # POST /api/spaces/{space}/blocks/{block}/reservations + It 'Create Two Block Reservations' { + $bodyA = @{ + size = 24 + desc = "Test Reservation A" + } - $reservation.Space -eq "TestSpaceA" | Should -Be $true - $reservation.Block -eq "TestBlockA" | Should -Be $true - $reservation.Desc -eq "Test Reservation C" | Should -Be $true - $reservation.Cidr -eq "10.1.4.0/24" | Should -Be $true - $reservation.SettledOn -eq $null | Should -Be $true - } + $bodyB = @{ + size = 24 + desc = "Test Reservation B" + } - # DELETE /api/spaces/{space}/blocks/{block}/reservations/{reservationId} - It 'Delete a Specific Reservation' { - $query = @{ - settled = $true + $bodyC = @{ + size = 24 + desc = "Test Reservation C" + } + + $script:reservationA, $reservationAStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyA + $script:reservationB, $reservationBStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyB + $script:reservationC, $reservationCStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyC + + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' + + $reservationAStatus | Should -Be 201 + $reservationBStatus | Should -Be 201 + $reservationCStatus | Should -Be 201 + $reservationsStatus | Should -Be 200 + $reservations.Count | Should -Be 3 + + $reservationA = $reservations | Where-Object { $_.Id -eq $script:reservationA.Id } | Select-Object -First 1 + $reservationB = $reservations | Where-Object { $_.Id -eq $script:reservationB.Id } | Select-Object -First 1 + $reservationC = $reservations | Where-Object { $_.Id -eq $script:reservationC.Id } | Select-Object -First 1 + + $reservationA | Should -Not -BeNullOrEmpty + $reservationB | Should -Not -BeNullOrEmpty + $reservationC | Should -Not -BeNullOrEmpty + + $reservationA.Space | Should -Be "TestSpaceA" + $reservationA.Block | Should -Be "TestBlockA" + $reservationA.Desc | Should -Be "Test Reservation A" + $reservationA.Cidr | Should -Be "10.1.2.0/24" + $reservationA.SettledOn | Should -Be $null + + $reservationB.Space | Should -Be "TestSpaceA" + $reservationB.Block | Should -Be "TestBlockA" + $reservationB.Desc | Should -Be "Test Reservation B" + $reservationB.Cidr | Should -Be "10.1.3.0/24" + $reservationB.SettledOn | Should -Be $null + + $reservationC.Space | Should -Be "TestSpaceA" + $reservationC.Block | Should -Be "TestBlockA" + $reservationC.Desc | Should -Be "Test Reservation C" + $reservationC.Cidr | Should -Be "10.1.4.0/24" + $reservationC.SettledOn | Should -Be $null } - Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationC.Id)" + # POST /api/spaces/{space}/blocks/{block}/reservations + It 'Reject Block Reservation with Conflicting CIDR and Search Flags' { + $body = @{ + cidr = '10.1.5.0/24' + reverse_search = $true + desc = 'Invalid Reservation Options' + } - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $body } | Should -Throw + } - $reservations.Count | Should -Be 3 + # POST /api/spaces/{space}/blocks/{block}/reservations + It 'Reject Specific Block Reservation CIDR Overlap' { + $body = @{ + cidr = $script:reservationA.Cidr + desc = 'Overlapping Reservation CIDR' + } - $reservations[0].SettledOn -eq $null | Should -Be $false - $reservations[0].Status -eq "fulfilled" | Should -Be $true - $reservations[1].SettledOn -eq $null | Should -Be $false - $reservations[1].Status -eq "cancelledByUser" | Should -Be $true - $reservations[2].SettledOn -eq $null | Should -Be $false - $reservations[2].Status -eq "cancelledByUser" | Should -Be $true - } -} + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $body } | Should -Throw + + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' -Context 'Tools' { - # POST /api/spaces - It 'Create Tools Space' { - $toolsSpace = @{ - name = 'ToolsSpace' - desc = 'Tools Space' + $reservationsStatus | Should -Be 200 + $reservations.Count | Should -Be 3 } - New-ApiResource '/spaces' $toolsSpace + # GET /api/spaces/{space}/blocks/{block}/available + It 'List Available Block Networks by CIDR Eligibility' -Tag @('LongRunning') { + $script:newNetAvailA = New-AzVirtualNetwork ` + -Name 'TestVNetAvail01' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix '10.1.200.0/24' - $spaces, $spacesStatus = Get-ApiResource '/spaces' + $script:newNetAvailB = New-AzVirtualNetwork ` + -Name 'TestVNetAvail02' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix '10.1.1.0/24' - $spaces.Count | Should -Be 2 - $spaces.Name -eq 'TestSpaceA' | Should -Be $true - $spaces.Name -eq 'ToolsSpace' | Should -Be $true - } + Start-Sleep -Seconds 60 + + $available, $availableStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/available' - # POST /api/spaces/{space}/blocks - It 'Create Tools Block' { - $toolsBlock = @{ - name = 'ToolsBlock' - cidr = '198.51.100.0/24' + $availableStatus | Should -Be 200 + + $available | Should -Contain $script:newNetAvailA.Id + $available | Should -Not -Contain $script:newNetAvailB.Id } - New-ApiResource '/spaces/ToolsSpace/blocks' $toolsBlock + # GET /api/spaces/{space}/blocks/{block}/available + It 'Exclude vNET Whose Prefix Overlaps Unfulfilled Reservation' -Tag @('LongRunning') { + $script:newNetAvailResv = New-AzVirtualNetwork ` + -Name 'TestVNetAvailResv' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix $script:reservationC.Cidr - $blocks, $blocksStatus = Get-ApiResource '/spaces/ToolsSpace/blocks' + Start-Sleep -Seconds 60 - $blocks.Count | Should -Be 1 + $available, $availableStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/available' - $blocks.Name -eq 'ToolsBlock' | Should -Be $true - $blocks.Cidr -eq '198.51.100.0/24' | Should -Be $true - } + $availableStatus | Should -Be 200 - # POST /api/tools/nextAvailableVnet - It 'Check Next Available vNET in Tools Block' { - $body = @{ - space = 'ToolsSpace' - blocks = @('ToolsBlock') - size = 24 + $available | Should -Not -Contain $script:newNetAvailResv.Id } - $newNet, $newNetStatus = New-ApiResource '/tools/nextAvailableVNet' $body + # GET /api/spaces/{space}/blocks/{block}/available + It 'Exclude vNET Already Associated Within Same Space via CIDR Containment' -Tag @('LongRunning') { + # newNetA (10.1.0.0/24) is already associated with TestBlockA and its prefix + # falls under TestBlockA (10.1.0.0/16). It should not appear as available for + # TestBlockOverlap (100.65.0.0/24) either, since the prefix doesn't fit that block. + $available, $availableStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockOverlap/available' - $newNet.Space -eq 'ToolsSpace' | Should -Be $true - $newNet.Block -eq 'ToolsBlock' | Should -Be $true - $newNet.Cidr -eq '198.51.100.0/24' | Should -Be $true - } + $availableStatus | Should -Be 200 + + $available | Should -Not -Contain $script:newNetA.Id + } + + # GET /api/spaces/{space}/blocks/{block}/available + It 'Allow Multi-Prefix vNET Across Blocks in Same Space' -Tag @('LongRunning') { + # Create a second block with a non-overlapping CIDR in the same space + $blockMultiPrefix = @{ + name = 'TestBlockMultiPfx' + cidr = '172.16.0.0/16' + } + + New-ApiResource '/spaces/TestSpaceA/blocks' $blockMultiPrefix + + # Create a vNET with two prefixes: one in TestBlockA, one in TestBlockMultiPfx + $script:newNetMultiPfx = New-AzVirtualNetwork ` + -Name 'TestVNetMultiPfx' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix @('10.1.201.0/24', '172.16.1.0/24') - # POST /api/spaces/{space}/blocks/{block}/networks - It 'Add a Virtual Network to Tools Block' { - $script:toolsNet = New-AzVirtualNetwork ` - -Name 'ToolsNet' ` - -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` - -Location 'westus3' ` - -AddressPrefix '198.51.100.0/24' + Start-Sleep -Seconds 60 - Start-Sleep -Seconds 60 + # Associate the vNET with TestBlockA + $body = @{ + id = $script:newNetMultiPfx.Id + } + + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body + + # It should still appear as available for TestBlockMultiPfx (different prefix fits there) + $available, $availableStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockMultiPfx/available' + + $availableStatus | Should -Be 200 - $body = @{ - id = $script:toolsNet.Id + $available | Should -Contain $script:newNetMultiPfx.Id } - New-ApiResource '/spaces/ToolsSpace/blocks/ToolsBlock/networks' $body + # GET /api/spaces/{space}/blocks/{block}/available + It 'Allow Cross-Space vNET Association' -Tag @('LongRunning') { + # Create a second space with a block whose CIDR overlaps TestBlockA + $spaceB = @{ + name = 'TestSpaceB' + desc = 'Test Space B' + } - $block, $blockStatus = Get-ApiResource '/spaces/ToolsSpace/blocks/ToolsBlock' + New-ApiResource '/spaces' $spaceB - $($block.vnets | Select-Object -ExpandProperty id) -contains $script:toolsNet.Id | Should -Be $true - } + $blockB = @{ + name = 'TestBlockB' + cidr = '10.1.0.0/16' + } + + New-ApiResource '/spaces/TestSpaceB/blocks' $blockB + + # newNetA (10.1.0.0/24) is already associated with TestSpaceA/TestBlockA. + # It should still appear as available for TestSpaceB/TestBlockB since + # Spaces are independent logical boundaries. + $available, $availableStatus = Get-ApiResource '/spaces/TestSpaceB/blocks/TestBlockB/available' + + $availableStatus | Should -Be 200 + + $available | Should -Contain $script:newNetA.Id + } + + # Create an Azure Virtual Network w/ Reservation ID Tag and Verify it's Automatically Imported into IPAM + It 'Import Virtual Network via Reservation ID' -Tag @('LongRunning') { + $script:newNetResvC = New-AzVirtualNetwork ` + -Name 'TestVNetResv' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix $script:reservationA.Cidr ` + -Tag @{ "X-IPAM-RES-ID" = $script:reservationA.Id } - # POST /api/tools/nextAvailableSubnet - It 'Check Next Available Subnet in Tools vNET' { - $body = @{ - vnet_id = $script:toolsNet.Id - size = 26 + Start-Sleep -Seconds 180 + + $query = @{ + settled = $true + } + + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query + + $networksStatus | Should -Be 200 + $reservationsStatus | Should -Be 200 + + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetA.Id + ($networks | Select-Object -ExpandProperty id) | Should -Contain $script:newNetResvC.Id + + $reservations.Count | Should -Be 3 + + $reservationASettled = $reservations | Where-Object { $_.Id -eq $script:reservationA.Id } | Select-Object -First 1 + $reservationBPending = $reservations | Where-Object { $_.Id -eq $script:reservationB.Id } | Select-Object -First 1 + $reservationCPending = $reservations | Where-Object { $_.Id -eq $script:reservationC.Id } | Select-Object -First 1 + + $reservationASettled | Should -Not -BeNullOrEmpty + $reservationBPending | Should -Not -BeNullOrEmpty + $reservationCPending | Should -Not -BeNullOrEmpty + + $reservationASettled.SettledOn | Should -Not -Be $null + $reservationASettled.Status | Should -Be "fulfilled" + $reservationBPending.SettledOn | Should -Be $null + $reservationBPending.Status | Should -Be "wait" + $reservationCPending.SettledOn | Should -Be $null + $reservationCPending.Status | Should -Be "wait" + } + + # DELETE /api/spaces/{space}/blocks/{block}/reservations + It 'Delete Reservations' { + $body = @( + $script:reservationB.Id + ) + + $query = @{ + settled = $true + } + + Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $body + + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query + + $reservationsStatus | Should -Be 200 + $reservations.Count | Should -Be 3 + + $reservationAFulfilled = $reservations | Where-Object { $_.Id -eq $script:reservationA.Id } | Select-Object -First 1 + $reservationBCancelled = $reservations | Where-Object { $_.Id -eq $script:reservationB.Id } | Select-Object -First 1 + $reservationCWaiting = $reservations | Where-Object { $_.Id -eq $script:reservationC.Id } | Select-Object -First 1 + + $reservationAFulfilled | Should -Not -BeNullOrEmpty + $reservationBCancelled | Should -Not -BeNullOrEmpty + $reservationCWaiting | Should -Not -BeNullOrEmpty + + $reservationAFulfilled.SettledOn | Should -Not -Be $null + $reservationAFulfilled.Status | Should -Be "fulfilled" + $reservationBCancelled.SettledOn | Should -Not -Be $null + $reservationBCancelled.Status | Should -Be "cancelledByUser" + $reservationCWaiting.SettledOn | Should -Be $null + $reservationCWaiting.Status | Should -Be "wait" } - $newSubnet, $newSubnetStatus = New-ApiResource '/tools/nextAvailableSubnet' $body + # GET /api/spaces/{space}/blocks/{block}/reservations/{reservationId} + It 'Get a Specific Reservation' { + + $reservation, $reservationStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationC.Id)" + + $reservationStatus | Should -Be 200 + + $reservation.Space | Should -Be "TestSpaceA" + $reservation.Block | Should -Be "TestBlockA" + $reservation.Desc | Should -Be "Test Reservation C" + $reservation.Cidr | Should -Be "10.1.4.0/24" + $reservation.SettledOn | Should -Be $null + } + + # DELETE /api/spaces/{space}/blocks/{block}/reservations/{reservationId} + It 'Delete a Specific Reservation' { + $query = @{ + settled = $true + } + + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationC.Id)" - $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query - $newSubnet.vnet_name -eq $script:toolsNet.Name | Should -Be $true - $newSubnet.resource_group -eq $script:toolsNet.ResourceGroupName | Should -Be $true - $newSubnet.subscription_id -eq $subscriptionId | Should -Be $true - $newSubnet.Cidr -eq '198.51.100.0/26' | Should -Be $true + $reservationsStatus | Should -Be 200 + $reservations.Count | Should -Be 3 + + $reservationAFulfilled = $reservations | Where-Object { $_.Id -eq $script:reservationA.Id } | Select-Object -First 1 + $reservationBCancelled = $reservations | Where-Object { $_.Id -eq $script:reservationB.Id } | Select-Object -First 1 + $reservationCCancelled = $reservations | Where-Object { $_.Id -eq $script:reservationC.Id } | Select-Object -First 1 + + $reservationAFulfilled | Should -Not -BeNullOrEmpty + $reservationBCancelled | Should -Not -BeNullOrEmpty + $reservationCCancelled | Should -Not -BeNullOrEmpty + + $reservationAFulfilled.SettledOn | Should -Not -Be $null + $reservationAFulfilled.Status | Should -Be "fulfilled" + $reservationBCancelled.SettledOn | Should -Not -Be $null + $reservationBCancelled.Status | Should -Be "cancelledByUser" + $reservationCCancelled.SettledOn | Should -Not -Be $null + $reservationCCancelled.Status | Should -Be "cancelledByUser" + } } - # POST /api/tools/cidrCheck - It 'Check Where CIDR is Used' { - $body = @{ - cidr = '198.51.100.0/24' + Context 'Tools' -Tag @('AzureLive') { + # POST /api/spaces + It 'Create Tools Space' { + $toolsSpace = @{ + name = 'ToolsSpace' + desc = 'Tools Space' + } + + New-ApiResource '/spaces' $toolsSpace + + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + $spaces.Count | Should -Be 3 + + $spaces.Name | Should -Contain 'TestSpaceA' + $spaces.Name | Should -Contain 'TestSpaceB' + $spaces.Name | Should -Contain 'ToolsSpace' + } + + # PATCH /api/spaces/{space} + It 'Reject Updating Space Name to Duplicate Existing Space Name' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = 'ToolsSpace' + } + ) + + { Update-ApiResource '/spaces/TestSpaceA' $update } | Should -Throw + + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + + $spaces.Name | Should -Contain 'TestSpaceA' + $spaces.Name | Should -Contain 'ToolsSpace' } - $cidrCheck, $cidrCheckStatus = New-ApiResource '/tools/cidrCheck' $body + # POST /api/spaces/{space}/blocks + It 'Create Tools Block' { + $toolsBlock = @{ + name = 'ToolsBlock' + cidr = '198.51.100.0/24' + } + + New-ApiResource '/spaces/ToolsSpace/blocks' $toolsBlock - $containers = @( - @{ - space = "ToolsSpace" - block = "ToolsBlock" + $blocks, $blocksStatus = Get-ApiResource '/spaces/ToolsSpace/blocks' + + $blocksStatus | Should -Be 200 + $blocks.Count | Should -Be 1 + + $blocks.Name | Should -Be 'ToolsBlock' + $blocks.Cidr | Should -Be '198.51.100.0/24' + } + + # POST /api/tools/nextAvailableVnet + It 'Check Next Available vNET in Tools Block' { + $body = @{ + space = 'ToolsSpace' + blocks = @('ToolsBlock') + size = 24 } - ) - $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + $newNet, $newNetStatus = New-ApiResource '/tools/nextAvailableVNet' $body + + $newNetStatus | Should -Be 200 + + $newNet.Space | Should -Be 'ToolsSpace' + $newNet.Block | Should -Be 'ToolsBlock' + $newNet.Cidr | Should -Be '198.51.100.0/24' + } + + # POST /api/spaces/{space}/blocks/{block}/networks + It 'Add a Virtual Network to Tools Block' -Tag @('LongRunning') { + $script:toolsNet = New-AzVirtualNetwork ` + -Name 'ToolsNet' ` + -ResourceGroupName $env:IPAM_RESOURCE_GROUP ` + -Location 'westus3' ` + -AddressPrefix '198.51.100.0/24' - $cidrCheck.name -eq $script:toolsNet.Name | Should -Be $true - $cidrCheck.id -eq $script:toolsNet.Id | Should -Be $true - $cidrCheck.resource_group -eq $script:toolsNet.ResourceGroupName | Should -Be $true - $cidrCheck.subscription_id -eq $subscriptionId | Should -Be $true - $cidrCheck.prefixes -contains '198.51.100.0/24' | Should -Be $true - $null -eq (Compare-Object $cidrCheck.containers $containers -Property {$_.space}) | Should -Be $true - $null -eq (Compare-Object $cidrCheck.containers $containers -Property {$_.block}) | Should -Be $true + Start-Sleep -Seconds 60 + + $body = @{ + id = $script:toolsNet.Id + } + + New-ApiResource '/spaces/ToolsSpace/blocks/ToolsBlock/networks' $body + + $block, $blockStatus = Get-ApiResource '/spaces/ToolsSpace/blocks/ToolsBlock' + + $blockStatus | Should -Be 200 + + ($block.vnets | Select-Object -ExpandProperty id) | Should -Contain $script:toolsNet.Id + } + + # POST /api/tools/nextAvailableSubnet + It 'Check Next Available Subnet in Tools vNET' { + $body = @{ + vnet_id = $script:toolsNet.Id + size = 26 + } + + $newSubnet, $newSubnetStatus = New-ApiResource '/tools/nextAvailableSubnet' $body + + $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + + $newSubnetStatus | Should -Be 200 + + $newSubnet.vnet_name | Should -Be $script:toolsNet.Name + $newSubnet.resource_group | Should -Be $script:toolsNet.ResourceGroupName + $newSubnet.subscription_id | Should -Be $subscriptionId + $newSubnet.Cidr | Should -Be '198.51.100.0/26' + } + + # POST /api/tools/cidrCheck + It 'Check Where CIDR is Used' { + $body = @{ + cidr = '198.51.100.0/24' + } + + $cidrCheck, $cidrCheckStatus = New-ApiResource '/tools/cidrCheck' $body + + $containers = @( + @{ + space = "ToolsSpace" + block = "ToolsBlock" + } + ) + + $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + + $cidrCheckStatus | Should -Be 200 + + $cidrCheck.name | Should -Be $script:toolsNet.Name + $cidrCheck.id | Should -Be $script:toolsNet.Id + $cidrCheck.resource_group | Should -Be $script:toolsNet.ResourceGroupName + $cidrCheck.subscription_id | Should -Be $subscriptionId + $cidrCheck.prefixes | Should -Contain '198.51.100.0/24' + + (Compare-Object $cidrCheck.containers $containers -Property {$_.space}) | Should -BeNullOrEmpty + (Compare-Object $cidrCheck.containers $containers -Property {$_.block}) | Should -BeNullOrEmpty + } } -} -Context 'Status' { - # GET /api/status - It 'Verify Status' { + Context 'Status' { + # GET /api/status + It 'Verify Status' { - $status, $statusCode = Get-ApiResource '/status' + $status, $statusCode = Get-ApiResource '/status' - $statusCode | Should -Be 200 + $statusCode | Should -Be 200 - $status.status -eq 'OK' | Should -Be $true - $status.stack -eq 'AppContainer' | Should -Be $true - $status.environment -eq 'AZURE_PUBLIC' | Should -Be $true + $status.status | Should -Be 'OK' + $status.stack | Should -Be 'AppContainer' + $status.environment | Should -Be 'AZURE_PUBLIC' + } } } diff --git a/ui/Dockerfile.deb b/ui/Dockerfile.deb index b71a3f2e..272f0016 100644 --- a/ui/Dockerfile.deb +++ b/ui/Dockerfile.deb @@ -1,6 +1,9 @@ ARG BASE_IMAGE=node:22-slim FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=80 @@ -20,7 +23,11 @@ WORKDIR /ipam ADD . . # Install Dependencies -RUN npm ci +RUN if [ "${PROD_BUILD}" = true ]; then \ + npm ci; \ +else \ + npm install; \ +fi # Build Application RUN npm run build diff --git a/ui/Dockerfile.rhel b/ui/Dockerfile.rhel index 15b0025a..4856f94c 100644 --- a/ui/Dockerfile.rhel +++ b/ui/Dockerfile.rhel @@ -1,6 +1,9 @@ -ARG BASE_IMAGE=registry.access.redhat.com/ubi8/nodejs-22 +ARG BASE_IMAGE=registry.access.redhat.com/ubi9/nodejs-22 FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=8080 @@ -20,7 +23,11 @@ USER root ADD . . # Install Dependencies -RUN npm ci +RUN if [ "${PROD_BUILD}" = true ]; then \ + npm ci; \ +else \ + npm install; \ +fi # Build Application RUN npm run build diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 98ccad66..ea7b9df2 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -1,141 +1,95 @@ import js from "@eslint/js"; import globals from "globals"; -import react from "eslint-plugin-react"; -import hooks from "eslint-plugin-react-hooks"; -import jest from "eslint-plugin-jest"; +import eslintReact from "@eslint-react/eslint-plugin"; +import reactCompiler from "eslint-plugin-react-compiler"; export default [ + // Ignore build output + { ignores: ["dist/"] }, + + // Base ESLint recommended rules js.configs.recommended, + + // Configuration for root-level JS files (config files, etc.) { files: ["*.js", "*.mjs", "*.cjs"], languageOptions: { - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, + ecmaVersion: "latest", + sourceType: "module", globals: { ...globals.node, - } + }, }, rules: { "no-unused-vars": "off", "no-prototype-builtins": "off", - "no-constant-binary-expression": "off" + "no-constant-binary-expression": "off", }, }, + + // Configuration for source JS files (non-JSX) { files: ["src/**/*.js"], - plugins: { - jest - }, languageOptions: { - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, + ecmaVersion: "latest", + sourceType: "module", globals: { ...globals.node, ...globals.browser, - ...globals.jest - } + }, }, rules: { "no-unused-vars": "off", "no-prototype-builtins": "off", - "no-constant-binary-expression": "off" + "no-constant-binary-expression": "off", }, }, + + // React recommended rules (scoped to JSX files) + { + ...eslintReact.configs.recommended, + files: ["src/**/*.jsx"], + }, + + // Disable RSC rules (Vite SPA, not using React Server Components) { files: ["src/**/*.jsx"], - plugins: { - react, - "react-hooks": hooks, - jest - }, - settings: { - react: { - version: "detect", - } - }, - languageOptions: { - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 'latest', - sourceType: 'module', - }, - globals: { - ...globals.node, - ...globals.browser, - ...globals.jest - } - }, rules: { - "no-unused-vars": "off", - "no-prototype-builtins": "off", - "react/prop-types": "off", - "react/display-name": "off", - "react/no-unescaped-entities": "off", - "no-constant-binary-expression": "off" + "@eslint-react/rsc/function-definition": "off", + "@eslint-react/no-nested-component-definitions": "warn", }, }, + + // React Compiler rules (replaces eslint-plugin-react-hooks) { - files: ["src/**/*.ts"], - plugins: { - jest - }, - languageOptions: { - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - globals: { - ...globals.node, - ...globals.browser, - ...globals.jest - } - }, + ...reactCompiler.configs.recommended, + files: ["src/**/*.jsx"], rules: { - "no-unused-vars": "off", - "no-prototype-builtins": "off", - "no-constant-binary-expression": "off" + "react-compiler/react-compiler": "warn", }, }, + + // Configuration for React JSX files { - files: ["src/**/*.tsx"], - plugins: { - react, - "react-hooks": hooks, - jest - }, - settings: { - react: { - version: "detect", - } - }, + files: ["src/**/*.jsx"], languageOptions: { + ecmaVersion: "latest", + sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true, }, - ecmaVersion: 'latest', - sourceType: 'module', }, globals: { ...globals.node, ...globals.browser, - ...globals.jest - } + }, }, rules: { "no-unused-vars": "off", "no-prototype-builtins": "off", - "react/prop-types": "off", - "react/display-name": "off", - "react/no-unescaped-entities": "off", - "no-constant-binary-expression": "off" + "no-constant-binary-expression": "off", }, }, ]; diff --git a/ui/index.html b/ui/index.html index 31f37877..3d56c20d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -29,7 +29,6 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - Azure IPAM diff --git a/ui/package-lock.json b/ui/package-lock.json index 33819a29..34534c6f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,114 +1,89 @@ { "name": "azure-ipam-ui", - "version": "3.5.0", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-ipam-ui", - "version": "3.5.0", + "version": "3.6.0", "dependencies": { - "@azure/msal-browser": "^4.22.1", - "@azure/msal-react": "^3.0.19", + "@azure/msal-browser": "^5.4.0", + "@azure/msal-react": "^5.0.6", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@inovua/reactdatagrid-community": "^5.10.2", - "@mui/icons-material": "^7.3.2", - "@mui/lab": "^7.0.0-beta.17", - "@mui/material": "^7.3.2", - "@reduxjs/toolkit": "^2.9.0", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "axios": "^1.11.0", + "@mui/icons-material": "^7.3.9", + "@mui/lab": "^7.0.1-beta.23", + "@mui/material": "^7.3.9", + "@reduxjs/toolkit": "^2.11.2", + "ag-grid-community": "^35.1.0", + "ag-grid-react": "^35.1.0", + "axios": "^1.13.6", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.4", - "globals": "^16.4.0", - "lodash": "^4.17.21", + "echarts-for-react": "^3.0.6", + "lodash": "^4.17.23", "md5": "^2.3.0", "moment": "^2.30.1", "notistack": "^3.0.2", "pluralize": "^8.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-draggable": "^4.5.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-redux": "^9.2.0", - "react-router": "^7.8.2", - "spinners-react": "^1.0.11", - "web-vitals": "^5.1.0" + "react-router": "^7.13.1", + "spinners-react": "^1.0.11" }, "devDependencies": { - "@eslint/js": "^9.35.0", - "@vitejs/plugin-react": "^5.0.2", - "eslint": "^9.35.0", - "eslint-plugin-jest": "^29.0.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "serve": "^14.2.5", - "vite": "^7.1.5", - "vite-plugin-eslint2": "^5.0.4" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", - "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==" - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "@eslint-react/eslint-plugin": "^2.13.0", + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^6.0.0", + "eslint": "^10.0.3", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", + "globals": "^17.4.0", + "serve": "^14.2.6", + "vite": "^8.0.0" } }, "node_modules/@azure/msal-browser": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.22.1.tgz", - "integrity": "sha512-/I76rBJpt5ZVfFXk+GkKxD4w1DZEbVpNn0aQjvRgnDnTYo3L/f8Oeo3R1O9eL/ccg5j1537iRLr7UwVhwnHtyg==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.4.0.tgz", + "integrity": "sha512-GvRbLNk26oPOPpnry4Ym8wtXrmdozGm2Sry5EKfui0siwnEuAKWEeMLLyosDo5nVEIIDO1C2t/+HpVzqqCWlfQ==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.12.0" + "@azure/msal-common": "16.2.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.12.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.12.0.tgz", - "integrity": "sha512-4ucXbjVw8KJ5QBgnGJUeA07c8iznwlk5ioHIhI4ASXcXgcf2yRFhWzYOyWg/cI49LC9ekpFJeQtO3zjDTbl6TQ==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz", + "integrity": "sha512-ge0nGzTLmEE5lg7tSCbTBrYqMGkpFQeQEtqfcKPuGJn/FPFf8Xz51uDfZsm5xpstNZGMYPhHvnYbL8OeNp/aLw==", "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-react": { - "version": "3.0.19", - "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.19.tgz", - "integrity": "sha512-309fo4+V0vUlZolMDv2w+JlZBH1Fr2/vpPtMbZNhGYjKrexEBWNx3uAPVCa4Vyf/egWxXYTXAcbRhd6+Wlp8Lg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-5.0.6.tgz", + "integrity": "sha512-p0YTCdsx+jkZNR/3Awznn12LxlTcKdsH4/FBiIoD53axn6A4y8g+9sng716PtxMvXSGaRIZo9JKM/yfFdz/+oQ==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20" }, "peerDependencies": { - "@azure/msal-browser": "^4.21.0", - "react": "^16.8.0 || ^17 || ^18 || ^19" + "@azure/msal-browser": "^5.4.0", + "react": "^19.2.1" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -117,9 +92,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -127,22 +102,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -165,13 +140,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -180,14 +155,27 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -197,6 +185,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -206,29 +216,43 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -237,6 +261,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", @@ -247,6 +284,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -257,9 +326,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -276,26 +345,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -304,30 +373,16 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -337,40 +392,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -378,18 +433,76 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/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/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/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/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/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/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -528,581 +641,297 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-react/ast": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-2.13.0.tgz", + "integrity": "sha512-43+5gmqV3MpatTzKnu/V2i/jXjmepvwhrb9MaGQvnXHQgq9J7/C7VVCCcwp6Rvp2QHAFquAAdvQDSL8IueTpeA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@eslint-react/eff": "2.13.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/typescript-estree": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "string-ts": "^2.3.1" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-react/core": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-2.13.0.tgz", + "integrity": "sha512-m62XDzkf1hpzW4sBc7uh7CT+8rBG2xz/itSADuEntlsg4YA7Jhb8hjU6VHf3wRFDwyfx5VnbV209sbJ7Azey0Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-react/eff": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/eff/-/eff-2.13.0.tgz", + "integrity": "sha512-rEH2R8FQnUAblUW+v3ZHDU1wEhatbL1+U2B1WVuBXwSKqzF7BGaLqCPIU7o9vofumz5MerVfaCtJgI8jYe2Btg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-react/eslint-plugin": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-2.13.0.tgz", + "integrity": "sha512-iaMXpqnJCTW7317hg8L4wx7u5aIiPzZ+d1p59X8wXFgMHzFX4hNu4IfV8oygyjmWKdLsjKE9sEpv/UYWczlb+A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/type-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "eslint-plugin-react-dom": "2.13.0", + "eslint-plugin-react-hooks-extra": "2.13.0", + "eslint-plugin-react-naming-convention": "2.13.0", + "eslint-plugin-react-rsc": "2.13.0", + "eslint-plugin-react-web-api": "2.13.0", + "eslint-plugin-react-x": "2.13.0", + "ts-api-utils": "^2.4.0" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-react/shared": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-2.13.0.tgz", + "integrity": "sha512-IOloCqrZ7gGBT4lFf9+0/wn7TfzU7JBRjYwTSyb9SDngsbeRrtW95ZpgUpS8/jen1wUEm6F08duAooTZ2FtsWA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@eslint-react/eff": "2.13.0", + "@typescript-eslint/utils": "^8.55.0", + "ts-pattern": "^5.9.0", + "zod": "^3.25.0 || ^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-react/var": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-2.13.0.tgz", + "integrity": "sha512-dM+QaeiHR16qPQoJYg205MkdHYSWVa2B7ore5OFpOPlSwqDV3tLW7I+475WjbK7potq5QNPTxRa7VLp9FGeQqA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -1167,27 +996,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inovua/reactdatagrid-community": { - "version": "5.10.2", - "resolved": "https://registry.npmjs.org/@inovua/reactdatagrid-community/-/reactdatagrid-community-5.10.2.tgz", - "integrity": "sha512-XUsiqA1IMXPCo0UgVRZJQAeIe3lj+otmoptq+JtM0XzYuqsEDe7XRcKIUMuhoghkm933xkkP3Q2xnTYJgIegsQ==", - "dependencies": { - "@types/lodash.debounce": "^4.0.6", - "@types/lodash.throttle": "^4.1.6", - "eventemitter3": "^4.0.7", - "fast-deep-equal": "^3.1.1", - "lodash.debounce": "^4.0.8", - "lodash.throttle": "^4.1.1", - "object-assign": "^4.1.1", - "resize-observer-polyfill": "^1.5.1", - "shallowequal": "0.2.2" - }, - "peerDependencies": { - "prop-types": ">=15.5.0", - "react": ">=16.8.0-0", - "react-dom": ">=16.8.0-0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -1198,12 +1006,23 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -1222,9 +1041,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz", - "integrity": "sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz", + "integrity": "sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==", "license": "MIT", "funding": { "type": "opencollective", @@ -1232,12 +1051,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.2.tgz", - "integrity": "sha512-TZWazBjWXBjR6iGcNkbKklnwodcwj0SrChCNHc9BhD9rBgET22J1eFhHsEmvSvru9+opDy3umqAimQjokhfJlQ==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.9.tgz", + "integrity": "sha512-BT+zPJXss8Hg/oEMRmHl17Q97bPACG4ufFSfGEdhiE96jOyR5Dz1ty7ZWt1fVGR0y1p+sSgEwQT/MNZQmoWDCw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3" + "@babel/runtime": "^7.28.6" }, "engines": { "node": ">=14.0.0" @@ -1247,7 +1066,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.3.2", + "@mui/material": "^7.3.9", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1258,15 +1077,15 @@ } }, "node_modules/@mui/lab": { - "version": "7.0.0-beta.17", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-7.0.0-beta.17.tgz", - "integrity": "sha512-H8tSINm6Xgbi7o49MplAwks4tAEE6SpFNd9l7n4NURl0GSpOv0CZvgXKSJt4+6TmquDhE7pomHpHWJiVh/2aCg==", + "version": "7.0.1-beta.23", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-7.0.1-beta.23.tgz", + "integrity": "sha512-661LhBtL33DWeRk7DXXu4LvbHUmTRkoybiVgKkdLx6gA4Nbr1r6B1U+yZGcTm5GfY25nrtS083aoy3P0wuuJ3A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/system": "^7.3.2", - "@mui/types": "^7.4.6", - "@mui/utils": "^7.3.2", + "@babel/runtime": "^7.28.6", + "@mui/system": "^7.3.9", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.9", "clsx": "^2.1.1", "prop-types": "^15.8.1" }, @@ -1280,8 +1099,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material": "^7.3.2", - "@mui/material-pigment-css": "^7.3.2", + "@mui/material": "^7.3.9", + "@mui/material-pigment-css": "^7.3.9", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1302,22 +1121,22 @@ } }, "node_modules/@mui/material": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", - "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz", + "integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/core-downloads-tracker": "^7.3.2", - "@mui/system": "^7.3.2", - "@mui/types": "^7.4.6", - "@mui/utils": "^7.3.2", + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.9", + "@mui/system": "^7.3.9", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.9", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", - "csstype": "^3.1.3", + "csstype": "^3.2.3", "prop-types": "^15.8.1", - "react-is": "^19.1.1", + "react-is": "^19.2.3", "react-transition-group": "^4.4.5" }, "engines": { @@ -1330,7 +1149,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.2", + "@mui/material-pigment-css": "^7.3.9", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1351,13 +1170,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.2.tgz", - "integrity": "sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.9.tgz", + "integrity": "sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/utils": "^7.3.2", + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.9", "prop-types": "^15.8.1" }, "engines": { @@ -1378,16 +1197,16 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.2.tgz", - "integrity": "sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.9.tgz", + "integrity": "sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", + "@babel/runtime": "^7.28.6", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", + "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "engines": { @@ -1412,18 +1231,18 @@ } }, "node_modules/@mui/system": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.2.tgz", - "integrity": "sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.9.tgz", + "integrity": "sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/private-theming": "^7.3.2", - "@mui/styled-engine": "^7.3.2", - "@mui/types": "^7.4.6", - "@mui/utils": "^7.3.2", + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.9", + "@mui/styled-engine": "^7.3.9", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.9", "clsx": "^2.1.1", - "csstype": "^3.1.3", + "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "engines": { @@ -1452,12 +1271,12 @@ } }, "node_modules/@mui/types": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", - "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3" + "@babel/runtime": "^7.28.6" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1469,17 +1288,17 @@ } }, "node_modules/@mui/utils": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", - "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/types": "^7.4.6", + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.1" + "react-is": "^19.2.3" }, "engines": { "node": ">=14.0.0" @@ -1498,39 +1317,41 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@oxc-project/runtime": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@popperjs/core": { @@ -1543,14 +1364,14 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", - "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", + "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" @@ -1568,54 +1389,10 @@ } } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.34", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", - "integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], @@ -1624,12 +1401,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], @@ -1638,12 +1418,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], @@ -1652,26 +1435,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], @@ -1680,26 +1452,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], @@ -1708,12 +1469,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], @@ -1722,12 +1486,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], @@ -1736,26 +1503,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", "cpu": [ "ppc64" ], @@ -1764,54 +1520,49 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ - "s390x" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], @@ -1820,54 +1571,66 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], @@ -1876,7 +1639,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.0.0", @@ -1890,135 +1663,31 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", - "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.0.0" + "tslib": "^2.4.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@tybys/wasm-util/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, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } + "license": "0BSD", + "optional": true }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2034,27 +1703,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", - "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==" - }, - "node_modules/@types/lodash.debounce": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", - "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.throttle": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz", - "integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2100,14 +1748,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", - "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0" + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2115,13 +1765,21 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", - "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2130,21 +1788,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", - "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", "dev": true, - "dependencies": { - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2152,42 +1801,122 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2196,15 +1925,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", - "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.15.0", - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/typescript-estree": "8.15.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2214,22 +1944,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", - "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2240,24 +1967,29 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz", - "integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.0.tgz", + "integrity": "sha512-Bu5/eP6td3WI654+tRq+ryW1PbgA90y5pqMKpb3U7UpNk6VjI53P/ncPUd192U9dSrepLy7DHnq1XEMDz5H++w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.3", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.34", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "1.0.0-rc.7" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7", + "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/@zeit/schemas": { @@ -2267,9 +1999,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2289,10 +2021,39 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-charts-types": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.1.0.tgz", + "integrity": "sha512-DytRM3CXli+Y013SC1Mr8lQBrhVTACK+11ilDHOhwUM0sRpmGuR51XFGcBKOliW1Vas1AycP31Cm3Pp0jx3hqw==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "35.1.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.1.0.tgz", + "integrity": "sha512-yWFQfRNjv3KUBkHHzFdDOYGjPcDMU0B8Up4qG651diFlGRUGEGVs94SK73niWvk1FDZdpV9oWrwq3f30/qAoVg==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.1.0" + } + }, + "node_modules/ag-grid-react": { + "version": "35.1.0", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.1.0.tgz", + "integrity": "sha512-n8pJh4RTpos8stzz91nEhTCZZdLy9bjQYAGxIxJ8ocVagnEsAk9T5Vz/VEKUhOGz36il68n7TVbVWSuUA3a+mg==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "35.1.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2351,6 +2112,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2359,6 +2121,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2395,193 +2158,20 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -2605,6 +2195,23 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/birecord": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.1.tgz", + "integrity": "sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==", + "dev": true, + "license": "(MIT OR Apache-2.0)" + }, "node_modules/boxen": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", @@ -2650,22 +2257,10 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2683,10 +2278,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2700,27 +2296,9 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, "node_modules/call-bind-apply-helpers": { @@ -2736,23 +2314,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2774,9 +2335,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", "dev": true, "funding": [ { @@ -2798,6 +2359,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2873,6 +2435,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2883,7 +2446,8 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2897,6 +2461,13 @@ "node": ">= 0.8" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -2967,6 +2538,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3031,91 +2603,33 @@ "node": "*" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" - }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "ms": "^2.1.3" }, "engines": { - "node": ">= 0.4" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "engines": { "node": ">=4.0.0" @@ -3127,40 +2641,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3170,32 +2650,16 @@ "node": ">=0.4.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "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, - "dependencies": { - "esutils": "^2.0.2" - }, + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "peer": true - }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3237,9 +2701,9 @@ } }, "node_modules/echarts-for-react": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.4.tgz", - "integrity": "sha512-rc7SNdr0JoMTkMJspp9ejlZAoipv2mMwbI30ggIgxSZMELdX36C2aJREvJ9OSkyetC/RoO1s7VrefXUrUAMClg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3251,9 +2715,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.182", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz", - "integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==", + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", "dev": true, "license": "ISC" }, @@ -3271,75 +2735,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3357,34 +2752,6 @@ "node": ">= 0.4" } }, - "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3412,74 +2779,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3502,34 +2801,30 @@ } }, "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3539,8 +2834,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3548,7 +2842,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -3562,148 +2856,279 @@ } } }, - "node_modules/eslint-plugin-jest": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.0.1.tgz", - "integrity": "sha512-EE44T0OSMCeXhDrrdsbKAhprobKkPtJTbQz5yEktysNpHeDZTAL1SfDTNKmcFfJkY6yrQLtTKZALrD3j/Gpmiw==", + "node_modules/eslint-plugin-react-compiler": { + "version": "19.1.0-rc.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz", + "integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.0.0" + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "hermes-parser": "^0.25.1", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.3" }, "engines": { - "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "jest": "*" + "eslint": ">=7" + } + }, + "node_modules/eslint-plugin-react-dom": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-2.13.0.tgz", + "integrity": "sha512-+2IZzQ1WEFYOWatW+xvNUqmZn55YBCufzKA7hX3XQ/8eu85Mp4vnlOyNvdVHEOGhUnGuC6+9+zLK+IlEHKdKLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/core": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "compare-versions": "^6.1.1", + "ts-pattern": "^5.9.0" }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "node_modules/eslint-plugin-react-hooks-extra": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks-extra/-/eslint-plugin-react-hooks-extra-2.13.0.tgz", + "integrity": "sha512-qIbha1nzuyhXM9SbEfrcGVqmyvQu7GAOB2sy9Y4Qo5S8nCqw4fSBxq+8lSce5Tk5Y7XzIkgHOhNyXEvUHRWFMQ==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" + "@eslint-react/ast": "2.13.0", + "@eslint-react/core": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/type-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "ts-pattern": "^5.9.0" }, "engines": { - "node": ">=4" + "node": ">=20.19.0" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "node_modules/eslint-plugin-react-naming-convention": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-2.13.0.tgz", + "integrity": "sha512-uSd25JzSg2R4p81s3Wqck0AdwRlO9Yc+cZqTEXv7vW8exGGAM3mWnF6hgrgdqVJqBEGJIbS/Vx1r5BdKcY/MHA==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/core": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/type-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "compare-versions": "^6.1.1", + "string-ts": "^2.3.1", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=10" + "node": ">=20.19.0" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "node_modules/eslint-plugin-react-rsc": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-2.13.0.tgz", + "integrity": "sha512-RaftgITDLQm1zIgYyvR51sBdy4FlVaXFts5VISBaKbSUB0oqXyzOPxMHasfr9BCSjPLKus9zYe+G/Hr6rjFLXQ==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" + "@eslint-react/ast": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "ts-pattern": "^5.9.0" }, - "bin": { - "resolve": "bin/resolve" + "engines": { + "node": ">=20.19.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-react-web-api": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-2.13.0.tgz", + "integrity": "sha512-nmJbzIAte7PeAkp22CwcKEASkKi49MshSdiDGO1XuN3f4N4/8sBfDcWbQuLPde6JiuzDT/0+l7Gi8wwTHtR1kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/core": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "birecord": "^0.1.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/eslint-plugin-react-x": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-2.13.0.tgz", + "integrity": "sha512-cMNX0+ws/fWTgVxn52qAQbaFF2rqvaDAtjrPUzY6XOzPjY0rJQdR2tSlWJttz43r2yBfqu+LGvHlGpWL2wfpTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "2.13.0", + "@eslint-react/core": "2.13.0", + "@eslint-react/eff": "2.13.0", + "@eslint-react/shared": "2.13.0", + "@eslint-react/var": "2.13.0", + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/type-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0", + "compare-versions": "^6.1.1", + "is-immutable-type": "^5.0.1", + "ts-api-utils": "^2.4.0", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -3729,17 +3154,11 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3749,11 +3168,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3782,34 +3196,6 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3823,14 +3209,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -3862,18 +3256,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -3915,15 +3297,16 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -3933,26 +3316,10 @@ } } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3971,6 +3338,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3987,36 +3355,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4076,24 +3414,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4107,9 +3427,10 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4118,22 +3439,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/goober": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", @@ -4154,53 +3459,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/has-symbols": { @@ -4240,6 +3505,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4273,9 +3555,10 @@ } }, "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -4305,129 +3588,22 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "engines": { - "node": ">=8" - } - }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", @@ -4442,41 +3618,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -4501,22 +3642,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4526,25 +3651,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4557,55 +3663,20 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/is-immutable-type": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/is-immutable-type/-/is-immutable-type-5.0.1.tgz", + "integrity": "sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@typescript-eslint/type-utils": "^8.0.0", + "ts-api-utils": "^2.0.0", + "ts-declaration-location": "^1.0.4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "*", + "typescript": ">=4.7.4" } }, "node_modules/is-port-reachable": { @@ -4620,58 +3691,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "engines": { "node": ">=8" @@ -4680,103 +3703,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -4789,55 +3715,17 @@ "node": ">=8" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "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==" }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -4886,41 +3774,287 @@ "node": ">=6" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=4.0" + "node": ">= 0.8.0" + } + }, + "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, + "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, + "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, + "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, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "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, - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "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, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -4944,50 +4078,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash._getnative": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" - }, - "node_modules/lodash.isarray": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==" - }, - "node_modules/lodash.keys": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", - "integrity": "sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==", - "dependencies": { - "lodash._getnative": "^3.0.0", - "lodash.isarguments": "^3.0.0", - "lodash.isarray": "^3.0.0" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -5010,15 +4104,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5044,40 +4129,6 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5090,6 +4141,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -5106,19 +4158,12 @@ "node": ">=6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5184,9 +4229,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -5240,102 +4285,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-headers": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", @@ -5378,24 +4327,6 @@ "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5467,7 +4398,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "dev": true + "dev": true, + "license": "(WTFPL OR MIT)" }, "node_modules/path-key": { "version": "3.1.1", @@ -5487,7 +4419,8 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -5523,20 +4456,10 @@ "node": ">=4" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -5571,38 +4494,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "peer": true - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5628,35 +4519,17 @@ "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/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5686,48 +4559,30 @@ } }, "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==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "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-draggable": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", - "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "clsx": "^2.1.1", - "prop-types": "^15.8.1" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" + "react": "^19.2.4" } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "license": "MIT" }, "node_modules/react-redux": { @@ -5753,20 +4608,10 @@ } } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-router": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", - "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5796,78 +4641,22 @@ "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" } }, "node_modules/registry-auth-token": { @@ -5897,6 +4686,7 @@ "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" } @@ -5906,11 +4696,6 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -5935,98 +4720,46 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", @@ -6049,49 +4782,11 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -6103,14 +4798,14 @@ } }, "node_modules/serve": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", - "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", "dev": true, "license": "MIT", "dependencies": { "@zeit/schemas": "2.36.0", - "ajv": "8.12.0", + "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", @@ -6118,7 +4813,7 @@ "clipboardy": "3.0.0", "compression": "1.8.1", "is-port-reachable": "4.0.0", - "serve-handler": "6.1.6", + "serve-handler": "6.1.7", "update-check": "1.5.4" }, "bin": { @@ -6129,15 +4824,16 @@ } }, "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", - "minimatch": "3.1.2", + "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" @@ -6148,6 +4844,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6157,6 +4854,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "~1.33.0" }, @@ -6165,15 +4863,16 @@ } }, "node_modules/serve/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -6196,7 +4895,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/set-cookie-parser": { "version": "2.7.1", @@ -6204,61 +4904,6 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shallowequal": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-0.2.2.tgz", - "integrity": "sha512-ePvPM7jqd9KrZdgSTN4bECWxeRcu+ysyZlSx8gPcDaYElHrDm7+1l5Q3QLbMm6t/7ejhS9U1ffGXybXs2j+qGw==", - "dependencies": { - "lodash.keys": "^3.1.2" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6280,82 +4925,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6371,157 +4940,54 @@ "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spinners-react": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/spinners-react/-/spinners-react-1.0.11.tgz", - "integrity": "sha512-CpQZixAI3dW+nLuJSrOTz4bDroSwLoIO5ljFt8wc1qy39yhK6MUubKnPD3z9/xIwNWFqy3mMRlW1mik/Fgqjow==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^16.x || ^17.x || ^18.x || ^19.x", - "@types/react-dom": "^16.x || ^17.x || ^18.x || ^19.x", - "react": "^16.x || ^17.x || ^18.x || ^19.x", - "react-dom": "^16.x || ^17.x || ^18.x || ^19.x" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "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, - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, + "node_modules/spinners-react": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/spinners-react/-/spinners-react-1.0.11.tgz", + "integrity": "sha512-CpQZixAI3dW+nLuJSrOTz4bDroSwLoIO5ljFt8wc1qy39yhK6MUubKnPD3z9/xIwNWFqy3mMRlW1mik/Fgqjow==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@types/react": "^16.x || ^17.x || ^18.x || ^19.x", + "@types/react-dom": "^16.x || ^17.x || ^18.x || ^19.x", + "react": "^16.x || ^17.x || ^18.x || ^19.x", + "react-dom": "^16.x || ^17.x || ^18.x || ^19.x" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "node_modules/string-ts": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz", + "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { @@ -6560,30 +5026,6 @@ "node": ">=6" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -6593,6 +5035,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -6628,30 +5071,49 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8.0" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/ts-api-utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", - "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "node_modules/ts-declaration-location": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", + "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==", "dev": true, - "engines": { - "node": ">=16" + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "picomatch": "^4.0.2" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.0.0" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -6682,89 +5144,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -6774,29 +5159,10 @@ "node": ">=14.17" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -6839,6 +5205,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -6863,17 +5230,17 @@ } }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", + "@oxc-project/runtime": "0.115.0", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "bin": { @@ -6890,9 +5257,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.0.0-alpha.31", + "esbuild": "^0.27.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", @@ -6905,13 +5273,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -6937,43 +5308,6 @@ } } }, - "node_modules/vite-plugin-eslint2": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/vite-plugin-eslint2/-/vite-plugin-eslint2-5.0.4.tgz", - "integrity": "sha512-3Yc7K2R/RrONB9JtwEh2Y40YP3tQi/3UiNHrwcYDsDBKDKnEu7B8PwmXLm7piDFRbxcnTPvgrV2LZnBpKP8JUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.2.0", - "debug": "^4.4.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/modyqyw" - }, - "peerDependencies": { - "@types/eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", - "rollup": "^2.0.0 || ^3.0.0 || ^4.0.0", - "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "rollup": { - "optional": true - } - } - }, - "node_modules/web-vitals": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", - "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6989,95 +5323,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/widest-line": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", @@ -7138,21 +5383,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7165,6 +5395,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz", + "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.24.4" + } + }, "node_modules/zrender": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 68ea03ad..8e66483a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,38 +4,34 @@ "type": "module", "private": true, "dependencies": { - "@azure/msal-browser": "^4.22.1", - "@azure/msal-react": "^3.0.19", + "@azure/msal-browser": "^5.4.0", + "@azure/msal-react": "^5.0.6", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@inovua/reactdatagrid-community": "^5.10.2", - "@mui/icons-material": "^7.3.2", - "@mui/lab": "^7.0.0-beta.17", - "@mui/material": "^7.3.2", - "@reduxjs/toolkit": "^2.9.0", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "axios": "^1.11.0", + "@mui/icons-material": "^7.3.9", + "@mui/lab": "^7.0.1-beta.23", + "@mui/material": "^7.3.9", + "@reduxjs/toolkit": "^2.11.2", + "ag-grid-community": "^35.1.0", + "ag-grid-react": "^35.1.0", + "axios": "^1.13.6", "echarts": "^6.0.0", - "echarts-for-react": "^3.0.4", - "globals": "^16.4.0", - "lodash": "^4.17.21", + "echarts-for-react": "^3.0.6", + "lodash": "^4.17.23", "md5": "^2.3.0", "moment": "^2.30.1", "notistack": "^3.0.2", "pluralize": "^8.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-draggable": "^4.5.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-redux": "^9.2.0", - "react-router": "^7.8.2", - "spinners-react": "^1.0.11", - "web-vitals": "^5.1.0" + "react-router": "^7.13.1", + "spinners-react": "^1.0.11" }, "scripts": { "start": "vite", - "build": "vite build" + "build": "vite build", + "lint": "eslint src/" }, "browserslist": { "production": [ @@ -50,14 +46,13 @@ ] }, "devDependencies": { - "@eslint/js": "^9.35.0", - "@vitejs/plugin-react": "^5.0.2", - "eslint": "^9.35.0", - "eslint-plugin-jest": "^29.0.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "serve": "^14.2.5", - "vite": "^7.1.5", - "vite-plugin-eslint2": "^5.0.4" + "@eslint-react/eslint-plugin": "^2.13.0", + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^6.0.0", + "eslint": "^10.0.3", + "eslint-plugin-react-compiler": "^19.1.0-rc.2", + "globals": "^17.4.0", + "serve": "^14.2.6", + "vite": "^8.0.0" } } diff --git a/ui/src/App.jsx b/ui/src/App.jsx index b0366777..99e98f60 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -20,6 +20,7 @@ import Slide from '@mui/material/Slide'; import Login from "./features/login/login"; import NavDrawer from './features/drawer/drawer'; +import AuthHandler from './msal/authHandler'; import { getDarkMode @@ -43,6 +44,7 @@ function App() { return (
+ { - const { getByText } = render( - - - - ); - - expect(getByText(/learn/i)).toBeInTheDocument(); -}); diff --git a/ui/src/features/DiscoverTable/Table.jsx b/ui/src/features/DiscoverTable/Table.jsx index 0aa8c86f..44a60472 100644 --- a/ui/src/features/DiscoverTable/Table.jsx +++ b/ui/src/features/DiscoverTable/Table.jsx @@ -1,160 +1,32 @@ import * as React from "react"; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useLocation } from "react-router"; -import { cloneDeep, pickBy, orderBy, isEmpty, merge } from 'lodash'; - -import ReactDataGrid from '@inovua/reactdatagrid-community'; -import filter from '@inovua/reactdatagrid-community/filter' -import '@inovua/reactdatagrid-community/index.css'; -import '@inovua/reactdatagrid-community/theme/default-dark.css' - import { useTheme } from '@mui/material/styles'; -import { useSnackbar } from "notistack"; - import { Box, Tooltip, IconButton, ClickAwayListener, - Typography, - Menu, - MenuItem, - ListItemIcon, - CircularProgress + Typography } from "@mui/material"; import { - ChevronRight, - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined + ChevronRight } from "@mui/icons-material"; import Shrug from "../../img/pam/Shrug"; -import { - selectViewSetting, - updateMeAsync -} from "../ipam/ipamSlice"; +import { DataGrid } from "../../global/grids"; import ItemDetails from "./Utils/Details"; import { TableContext } from "./TableContext"; -const filterTypes = merge({}, ReactDataGrid.defaultProps.filterTypes, { - array: { - name: 'array', - emptyValue: null, - operators: [ - { - name: 'contains', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? (value || []).findIndex(e => e.toLowerCase().includes(filterValue.toLowerCase())) >= 0 : true; - } - }, - { - name: 'notContains', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? value ? value.findIndex(e => e.toLowerCase().includes(filterValue.toLowerCase())) < 0 : false : true; - } - }, - { - name: 'eq', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? value ? value.map(e => e.toLowerCase()).includes(filterValue.toLowerCase()) : false : true; - } - }, - { - name: 'neq', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? value ? !value.map(e => e.toLowerCase()).includes(filterValue.toLowerCase()) : false : true; - } - }, - { - name: 'empty', - disableFilterEditor: true, - filterOnEmptyValue: true, - valueOnOperatorSelect: '', - fn: ({ value, filterValue, data }) => { - return (value || []).length === 0; - } - }, - { - name: 'notEmpty', - disableFilterEditor: true, - filterOnEmptyValue: true, - valueOnOperatorSelect: '', - fn: ({ value, filterValue, data }) => { - return (value || []).length !== 0; - } - } - ] - }, - string: { - name: 'string', - operators: [ - { - name: 'contains', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase().indexOf(filterValue.toLowerCase()) !== -1; - } - }, - { - name: 'notContains', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase().indexOf(filterValue.toLowerCase()) === -1; - } - }, - { - name: 'eq', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase() === filterValue.toLowerCase(); - } - }, - { - name: 'neq', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase() !== filterValue.toLowerCase(); - } - }, - { - name: 'empty', - disableFilterEditor: true, - filterOnEmptyValue: true, - valueOnOperatorSelect: '', - fn: ({ value, filterValue, data }) => { - return value === null || value === ''; - } - }, - { - name: 'notEmpty', - disableFilterEditor: true, - filterOnEmptyValue: true, - valueOnOperatorSelect: '', - fn: ({ value, filterValue, data }) => { - return value !== null && value !== ''; - } - }, - { - name: 'startsWith', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase().startsWith(filterValue.toLowerCase()); - } - }, - { - name: 'endsWith', - fn: ({ value, filterValue, data }) => { - return !filterValue ? true : (value || '').toLowerCase().endsWith(filterValue.toLowerCase()); - } - } - ] - } -}); +// ============================================================================ +// Styles +// ============================================================================ const openStyle = { right: 0, @@ -166,194 +38,151 @@ const closedStyle = { transition: "all 0.5s ease-in-out", }; -const gridStyle = { - height: '100%', - border: "1px solid rgba(224, 224, 224, 1)", - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.useContext(TableContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Converts a single filter entry to its AG Grid filter model fragment. + */ +function toAgGridFilter(entry) { + const { name, type, value } = entry; + + if (type === 'number') { + return { + [name]: { + filterType: 'number', + type: 'equals', + filter: value + } + }; + } - const viewSetting = useSelector(state => selectViewSetting(state, setting)); + return { + [name]: { + filterType: 'text', + type: 'contains', + filter: value + } + }; +} - const onClick = () => { - setMenuOpen(prev => !prev); +/** + * Maps filter state from location.state to AG Grid filter model format. + * Accepts a single filter object or an array of filter objects. + * + * @param {Object|Array} filterState + * @returns {Object|null} AG Grid filter model + */ +function mapFilterStateToAgGridModel(filterState) { + if (!filterState) return null; + + // Array of filters (multi-field drill-down) + if (Array.isArray(filterState)) { + if (filterState.length === 0) return null; + return filterState.reduce((model, entry) => { + if (entry?.name && entry?.value) { + Object.assign(model, toAgGridFilter(entry)); + } + return model; + }, {}); } - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } + // Single filter object (search bar / simple drill-down) + if (!filterState.name || !filterState.value) return null; + return toAgGridFilter(filterState); +} - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } +/** + * Ensures any columns targeted by the filter model are visible in the grid. + * This prevents the confusing UX of hidden columns being silently filtered. + */ +function showFilteredColumns(api, filterModel) { + if (!api || !filterModel) return; - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } + const hiddenFiltered = Object.keys(filterModel).filter((field) => { + const col = api.getColumn(field); + return col && !col.isVisible(); + }); - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) + if (hiddenFiltered.length > 0) { + api.setColumnsVisible(hiddenFiltered, true); + } } -export default function DiscoverTable(props) { - const { config, columns, filterSettings, detailsMap } = props.map; +// ============================================================================ +// Main Component +// ============================================================================ - const { enqueueSnackbar } = useSnackbar(); +export default function DiscoverTable(props) { + const { config, columns, detailsMap } = props.map; const [loading, setLoading] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); - const [gridData, setGridData] = React.useState(null); const [rowData, setRowData] = React.useState({}); - const [filterData, setFilterData] = React.useState(filterSettings); const [menuExpand, setMenuExpand] = React.useState(false); - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const stateData = useSelector(config.apiFunc); - const viewSetting = useSelector(state => selectViewSetting(state, config.setting)); - const dispatch = useDispatch(); - const timer = React.useRef(); + const gridApiRef = React.useRef(null); + const filterApplied = React.useRef(false); const location = useLocation(); - const theme = useTheme(); + // Handle grid ready - store API reference and apply URL-based filter + const handleGridReady = React.useCallback((params) => { + gridApiRef.current = params.api; + + // Apply filter from URL state if present and not already applied + if (location.state && !filterApplied.current) { + const filterModel = mapFilterStateToAgGridModel(location.state); + if (filterModel) { + // Small delay to ensure grid is fully initialized + setTimeout(() => { + showFilteredColumns(params.api, filterModel); + params.api.setFilterModel(filterModel); + filterApplied.current = true; + }, 100); + } + } + }, [location.state]); + + // Reset filter applied flag when location changes React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(timer.current); - - timer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); + filterApplied.current = false; + + // Apply new filter if grid is ready + if (gridApiRef.current && location.state) { + const filterModel = mapFilterStateToAgGridModel(location.state); + if (filterModel) { + showFilteredColumns(gridApiRef.current, filterModel); + gridApiRef.current.setFilterModel(filterModel); + filterApplied.current = true; + } } - }, [timer, sendResults]); + }, [location.state]); - function renderExpand(data) { + // Set loading to false when data is available + React.useEffect(() => { + if (stateData) { + setLoading(false); + } + }, [stateData]); + + // Render expand/details button for actions column + const actionsCellRenderer = React.useCallback((params) => { const onClick = (e) => { e.stopPropagation(); - setRowData(data); + setRowData(params.data); setMenuExpand(true); }; - - const flexCenter = { - display: "flex", - alignItems: "center", - justifyContent: "center" - } return ( - + ); - } - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } - - var body = [ - { "op": "add", "path": `/views/${config.setting}`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body})); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - let newColumns = [...columns]; - - newColumns.push( - { name: "id", header: () => , width: 50, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => renderExpand(data) } - ); - - const colsMap = newColumns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; - - return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, config.setting, viewSetting]); - - const resetConfig = React.useCallback(() => { - let newColumns = [...columns]; - - newColumns.push( - { name: "id", header: () => , width: 50, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => renderExpand(data) } - ); - - setColumnState(newColumns); - setColumnOrderState(newColumns.flatMap(({name}) => name)); - setColumnSortState({ name: 'name', dir: 1, type: 'string' }); - }, [columns, config.setting]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); }, []); - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[config, columns, viewSetting, columnState, loadConfig, resetConfig]); - - React.useEffect(() => { - if(location.state) { - var searchFilter = cloneDeep(filterSettings); - - const target = searchFilter.find((obj) => obj.name === location.state.name); - - Object.assign(target, location.state); - - setFilterData(searchFilter); - } - },[location, filterSettings]); - - React.useEffect(() => { - if(stateData) { - if(columnSortState) { - setGridData( - filter( - orderBy( - stateData, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ), - filterData, - filterTypes - ) - ); - } else { - setGridData(filter(stateData, filterData, filterTypes)); - } - } - },[stateData, filterData, columnSortState]); - - React.useEffect(() => { - gridData && setLoading(false); - },[gridData]); - - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - console.log(cellProps); - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - + // Render details panel function renderDetails() { return ( setMenuExpand(false)}> @@ -569,57 +227,33 @@ export default function DiscoverTable(props) { ); } - function NoRowsOverlay() { + // No rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( - + Nothing yet... ); - } + }, []); return ( - + {renderDetails()} - setFilterData(newFilterValue)} - // defaultSortInfo={{ name: 'name', dir: 1, type: 'string' }} - // defaultSortInfo={columnSortState} - // onSortInfoChange={(newSortInfo) => setColumnSortState(newSortInfo)} - onCellDoubleClick={onCellDoubleClick} - sortInfo={columnSortState} - emptyText={NoRowsOverlay} - style={gridStyle} + isLoading={loading} + noRowsOverlay={NoRowsOverlay} + actionsCellRenderer={actionsCellRenderer} + onGridReady={handleGridReady} /> ); } - -// data.map((item) => Object.entries(item).reduce((obj, [k, v]) => { Array.isArray(v) ? obj[k] = v.join(", ") : obj[k] = v; return obj; }, {})); diff --git a/ui/src/features/DiscoverTable/Utils/DrillDownCellRenderer.jsx b/ui/src/features/DiscoverTable/Utils/DrillDownCellRenderer.jsx new file mode 100644 index 00000000..7b51f94d --- /dev/null +++ b/ui/src/features/DiscoverTable/Utils/DrillDownCellRenderer.jsx @@ -0,0 +1,151 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router"; + +import { + Box, + IconButton, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; + +import { SubdirectoryArrowRight } from "@mui/icons-material"; + +/** + * AG Grid cell renderer that displays the cell value with an optional + * drill-down icon. The icon navigates to a child Discover tab with a + * pre-applied filter. + * + * cellRendererParams: + * targets: Array<{ + * label: string, - Menu item label (e.g. "Blocks") + * path: string, - Route path (e.g. "/discover/block") + * filterField: string | Array<{ field: string, valueFrom: string }>, + * - string: single filter using the cell value (e.g. "parent_space") + * - array: multi-field filter; each entry maps a target column (field) + * to a source row field (valueFrom) resolved from props.data + * hasChildrenSelector: Function - Redux selector returning a Set of parent names + * }> + */ +/** + * Internal hook that resolves which targets have children for a given value. + * Accepts up to two targets (the maximum in our config). Each selector is + * called unconditionally to satisfy the rules-of-hooks constraint. + */ +function useActiveTargets(targets, value) { + const set0 = useSelector(targets[0]?.hasChildrenSelector ?? selectNone); + const set1 = useSelector(targets[1]?.hasChildrenSelector ?? selectNone); + const sets = [set0, set1]; + + return targets.filter((_, i) => sets[i]?.has(value)); +} + +// Fallback selector that returns an empty Set (never matches) +const emptySet = new Set(); +const selectNone = () => emptySet; + +export default function DrillDownCellRenderer(props) { + const { value, data, colDef } = props; + const { targets = [] } = colDef.cellRendererParams || {}; + + const navigate = useNavigate(); + const [menuAnchor, setMenuAnchor] = React.useState(null); + + const activeTargets = useActiveTargets(targets, value); + + const handleNavigate = (target) => { + const { filterField, path } = target; + + // Support single-field (string) or multi-field (array) filter definitions + if (Array.isArray(filterField)) { + const filters = filterField.map((entry) => ({ + name: entry.field, + operator: "contains", + type: "string", + value: data?.[entry.valueFrom] ?? "", + })); + navigate(path, { state: filters }); + } else { + navigate(path, { + state: { + name: filterField, + operator: "contains", + type: "string", + value: value, + }, + }); + } + }; + + const handleIconClick = (e) => { + e.stopPropagation(); + + if (activeTargets.length === 1) { + handleNavigate(activeTargets[0]); + } else if (activeTargets.length > 1) { + setMenuAnchor(e.currentTarget); + } + }; + + const handleMenuClose = (e) => { + e?.stopPropagation?.(); + setMenuAnchor(null); + }; + + const handleMenuItemClick = (e, target) => { + e.stopPropagation(); + setMenuAnchor(null); + handleNavigate(target); + }; + + return ( + + {value} + {activeTargets.length > 0 && ( + <> + + + + + + {activeTargets.length > 1 && ( + e.stopPropagation()} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + > + {activeTargets.map((target) => ( + handleMenuItemClick(e, target)} + sx={{ fontSize: "0.875rem" }} + > + {target.label} + + ))} + + )} + + )} + + ); +} diff --git a/ui/src/features/DiscoverTable/Utils/InfoCellRenderer.jsx b/ui/src/features/DiscoverTable/Utils/InfoCellRenderer.jsx new file mode 100644 index 00000000..f0deb6c8 --- /dev/null +++ b/ui/src/features/DiscoverTable/Utils/InfoCellRenderer.jsx @@ -0,0 +1,74 @@ +import React from "react"; + +import { + Box, + Tooltip, +} from "@mui/material"; + +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +/** + * AG Grid cell renderer that displays a value with an optional info icon tooltip. + * + * cellRendererParams: + * condition: function(data) - returns true if the info icon should show + * message: string - tooltip text to display + * color: string - text color when condition is true + */ +export default function InfoCellRenderer(props) { + const { value, data, colDef } = props; + const { condition, message, color } = colDef.cellRendererParams || {}; + + // Check if condition is met (defaults to false if no condition provided) + const showInfo = condition ? condition(data) : false; + + if (!showInfo) { + return value; + } + + return ( + + {value} + + + + + + + ); +} diff --git a/ui/src/features/DiscoverTable/Utils/ProgressCellRenderer.jsx b/ui/src/features/DiscoverTable/Utils/ProgressCellRenderer.jsx new file mode 100644 index 00000000..c91917a2 --- /dev/null +++ b/ui/src/features/DiscoverTable/Utils/ProgressCellRenderer.jsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { + Box, + LinearProgress, +} from "@mui/material"; + +/** + * AG Grid cell renderer that displays a color-coded progress bar + * based on utilization percentage. + * + * - 0–70%: green (success) + * - 71–89%: yellow (warning) + * - 90%+: red (error) + */ +export default function ProgressCellRenderer(props) { + const raw = props.value; + const value = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 100)) : 0; + return ( + + = 0 && value <= 70 + ? "success" + : value > 70 && value < 90 + ? "warning" + : value >= 90 + ? "error" + : "info" + } + /> + + ); +} diff --git a/ui/src/features/admin/admin/admin.jsx b/ui/src/features/admin/admin/admin.jsx index 0831fc8c..1798f8eb 100644 --- a/ui/src/features/admin/admin/admin.jsx +++ b/ui/src/features/admin/admin/admin.jsx @@ -1,16 +1,11 @@ import * as React from "react"; -import { useSelector, useDispatch } from 'react-redux'; import { styled } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; -import { isEqual, isEmpty, pickBy, orderBy, throttle } from 'lodash'; +import { isEqual, throttle } from 'lodash'; -import ReactDataGrid from '@inovua/reactdatagrid-community'; -import '@inovua/reactdatagrid-community/index.css'; -import '@inovua/reactdatagrid-community/theme/default-dark.css' - -import { useTheme } from '@mui/material/styles'; +import { DataGrid } from "../../../global/grids"; import { Box, @@ -21,11 +16,7 @@ import { CircularProgress, Popper, Typography, - Menu, - MenuItem, - ListItemIcon, Button - // Switch } from "@mui/material"; import { @@ -34,13 +25,7 @@ import { QuestionMark, PersonSearch, SaveAlt, - HighlightOff, - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined + HighlightOff } from "@mui/icons-material"; import Shrug from "../../../img/pam/Shrug"; @@ -50,11 +35,6 @@ import { replaceAdmins } from "../../ipam/ipamAPI"; -import { - selectViewSetting, - updateMeAsync -} from "../../ipam/ipamSlice"; - import { callMsGraphUsersFilter, callMsGraphPrincipalsFilter @@ -111,143 +91,9 @@ const GridBody = styled("div")({ width: "100%" }); -const Update = styled("span")(({ theme }) => ({ - fontWeight: 'bold', - color: theme.palette.error.light, - textShadow: '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white' -})); - -const gridStyle = { - height: '100%', - border: "1px solid rgba(224, 224, 224, 1)", - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.useContext(AdminContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); - - const viewSetting = useSelector(state => selectViewSetting(state, setting)); - - const onClick = () => { - setMenuOpen(prev => !prev); - } - - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } - - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } - - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } - - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) -} - function RenderDelete(props) { - const { value } = props; - const { admins, setAdmins, selectionModel } = React.useContext(AdminContext); + const { data } = props; + const { admins, setAdmins, selectedId } = React.useContext(AdminContext); const flexCenter = { display: "flex", @@ -262,12 +108,12 @@ function RenderDelete(props) { color="error" sx={{ padding: 0, - display: (isEqual([value.id], Object.keys(selectionModel))) ? "flex" : "none" + display: (data.id === selectedId) ? "flex" : "none" }} disableFocusRipple disableTouchRipple disableRipple - onClick={() => setAdmins(admins.filter(x => x.id !== value.id))} + onClick={() => setAdmins(admins.filter(x => x.id !== data.id))} > @@ -305,16 +151,23 @@ function RenderType(props) { ); } +const popperStyle = { + popper: { + width: "fit-content" + } +}; + +function MyPopper(props) { + return ; +} + export default function Administration() { const { enqueueSnackbar } = useSnackbar(); const [admins, setAdmins] = React.useState(null); const [loadedAdmins, setLoadedAdmins] = React.useState(null); - const [gridData, setGridData] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState({}); + const [selectedId, setSelectedId] = React.useState(null); const [loading, setLoading] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); const [open, setOpen] = React.useState(false); const [options, setOptions] = React.useState(null); @@ -322,33 +175,41 @@ export default function Administration() { const [selected, setSelected] = React.useState(null); const [sending, setSending] = React.useState(false); - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const [appSearch, setAppSearch] = React.useState(false); - const viewSetting = useSelector(state => selectViewSetting(state, 'admins')); - const dispatch = useDispatch(); - - const saveTimer = React.useRef(); const adminLoadedRef = React.useRef(false); - const theme = useTheme(); + const TypeHeaderComponent = React.useCallback(() => ( + + + + ), []); const columns = React.useMemo(() => [ - { name: "type", header: () => , width: 40, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({value}) => , visible: true }, - { name: "name", header: "Name", type: "string", flex: 0.5, visible: true }, - { name: "email", header: "Email", type: "string", flex: 1, visible: true, render: ({value}) => value ? value : "N/A" }, - { name: "id", header: "Object ID", type: "string", flex: 0.75, visible: true }, - { name: "delete", header: () => , width: 50, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => , visible: true } - ], []); - - const filterValue = [ - { name: "name", operator: "contains", type: "string", value: "" }, - { name: "email", operator: "contains", type: "string", value: "" }, - { name: "id", operator: "contains", type: "string", value: "" } - ]; + { + field: "type", + headerName: "", + headerComponent: TypeHeaderComponent, + width: 50, + minWidth: 50, + maxWidth: 50, + resizable: false, + sortable: false, + filter: false, + suppressMovable: true, + suppressSizeToFit: true, + suppressAutoSize: true, + cellRenderer: RenderType, + cellStyle: { display: "flex", alignItems: "center", justifyContent: "center" } + }, + { field: "name", headerName: "Name", flex: 0.5, filter: true }, + { field: "email", headerName: "Email", flex: 1, filter: true, valueFormatter: (params) => params.value || "N/A" }, + { field: "id", headerName: "Object ID", flex: 0.75, filter: true } + ], [TypeHeaderComponent]); + + const actionsCellRenderer = React.useCallback((params) => { + return ; + }, []); const usersLoading = open && !options; const unchanged = isEqual(admins, loadedAdmins); @@ -419,18 +280,6 @@ export default function Administration() { admins && setLoading(false); }, [admins]); - React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); - function onSave() { (async () => { try { @@ -464,7 +313,7 @@ export default function Administration() { console.log("Admin already added!"); enqueueSnackbar('Admin already added!', { variant: 'error' }); } - + setSelected(null); } @@ -472,166 +321,12 @@ export default function Administration() { setAppSearch((current) => !current); }; - const popperStyle = { - popper: { - width: "fit-content" - } - }; - - const MyPopper = function (props) { - return ; - }; - - function onClick(data) { - var id = data.id; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - } - - return newSelectionModel; - }); - } - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } - - var body = [ - { "op": "add", "path": `/views/admins`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body })); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; - - return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); - - const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState({ name: 'name', dir: 1, type: 'string' }); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'delete'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); + const handleRowClicked = React.useCallback((event) => { + const data = event.data; + setSelectedId(prevId => prevId === data.id ? null : data.id); }, []); - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); - - React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - admins, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(admins); - } - },[admins, columnSortState]); - - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - - function NoRowsOverlay() { + const NoRowsOverlay = React.useCallback(() => { return ( @@ -640,10 +335,10 @@ export default function Administration() { ); - } + }, []); return ( - + @@ -665,7 +360,7 @@ export default function Administration() { {/* { appSearch ? : } */} -
`; - + return y; } } @@ -425,23 +425,23 @@ function parseNets(data, subscriptions) { if(!visibleNets.includes(peer.remote_network)) { const vNetPattern = "/Microsoft.Network/virtualNetworks/"; const vHubPattern = "/Microsoft.Network/virtualHubs/"; - + const resourceGroupPattern = "(?<=/resourceGroups/).+?(?=/)"; const subscriptionPattern = "(?<=/subscriptions/).+?(?=/)"; - + var vNetName = ''; - + if(peer.remote_network.includes(vNetPattern)) { vNetName = peer.remote_network.substr(peer.remote_network.indexOf(vNetPattern) + vNetPattern.length, peer.remote_network.length); } - + if(peer.remote_network.includes(vHubPattern)) { vNetName = peer.remote_network.substr(peer.remote_network.indexOf(vHubPattern) + vHubPattern.length, peer.remote_network.length); } - + const resourceGroup = peer.remote_network.match(resourceGroupPattern)[0]; const subscriptionId = peer.remote_network.match(subscriptionPattern)[0]; - + const subscriptionName = subscriptions.find(sub => sub.subscription_id === subscriptionId)?.name || 'Unknown'; let node = { @@ -539,7 +539,7 @@ function parseNets(data, subscriptions) { }).flat(); const links = linkArr.reduce( - (acc, curr) => + (acc, curr) => acc.find((v) => (v.source === curr.target && v.target === curr.source)) ? acc : [...acc, curr], [] ); @@ -639,8 +639,7 @@ const Reset = (props) => { ); }; -const Search = React.forwardRef((props, ref) => { - const { options, setDataFocus } = props; +const Search = ({ ref, options, setDataFocus }) => { const [value, setValue] = React.useState(null); const [inputValue, setInputValue] = React.useState(''); @@ -687,6 +686,8 @@ const Search = React.forwardRef((props, ref) => { return newOption; }); + optionData.sort((a, b) => a.name.localeCompare(b.name)); + setSearchOptions(optionData); } }, [options]); @@ -740,7 +741,55 @@ const Search = React.forwardRef((props, ref) => { }} /> ); -}); +}; + +function filterByVnet(options, target, previousTarget, currentMembers) { + const members = []; + + let filteredLinks = options.series[0].links.filter((item) => { + if(item.source === target) { + members.push(item.target); + return item; + } else if(item.target === target) { + members.push(item.source); + return item; + } + + return false; + }); + + let uniqueMembers = [...new Set(members)]; + + var indexOfPrevious = uniqueMembers.indexOf(previousTarget); + + if (indexOfPrevious !== -1) { + uniqueMembers.splice(indexOfPrevious, 1); + } + + let filteredData = options.series[0].data.filter((item) => { + if (uniqueMembers.includes(item.name) || item.name === target) { + return item; + } + + return false; + }); + + if(uniqueMembers.length > 0) { + uniqueMembers.forEach((member) => { + if(!currentMembers.includes(member)) { + const results = filterByVnet(options, member, target, [...new Set(currentMembers.concat(uniqueMembers))]); + + filteredData = filteredData.concat(results.data); + filteredLinks = filteredLinks.concat(results.links); + } + }); + } + + return { + data: [...new Set(filteredData)], + links: [...new Set(filteredLinks)] + }; +} const Peering = () => { const [options, setOptions] = React.useState(opt); @@ -772,60 +821,12 @@ const Peering = () => { } }, [subscriptions, networks, theme]); - function filterByVnet(options, target, previousTarget, currentMembers) { - const members = []; - - let filteredLinks = options.series[0].links.filter((item) => { - if(item.source === target) { - members.push(item.target); - return item; - } else if(item.target === target) { - members.push(item.source); - return item; - } - - return false; - }); - - let uniqueMembers = [...new Set(members)]; - - var indexOfPrevious = uniqueMembers.indexOf(previousTarget); - - if (indexOfPrevious !== -1) { - uniqueMembers.splice(indexOfPrevious, 1); - } - - let filteredData = options.series[0].data.filter((item) => { - if (uniqueMembers.includes(item.name) || item.name === target) { - return item; - } - - return false; - }); - - if(uniqueMembers.length > 0) { - uniqueMembers.forEach((member) => { - if(!currentMembers.includes(member)) { - const results = filterByVnet(options, member, target, [...new Set(currentMembers.concat(uniqueMembers))]); - - filteredData = filteredData.concat(results.data); - filteredLinks = filteredLinks.concat(results.links); - } - }); - } - - return { - data: [...new Set(filteredData)], - links: [...new Set(filteredLinks)] - }; - } - - const onEvents = { + const onEvents = React.useMemo(() => ({ click: onClick // restore: onRestore - }; + }), []); - function setDataFocus(target) { + const setDataFocus = React.useCallback((target) => { if(target) { let newOptions = cloneDeep(options); @@ -862,7 +863,7 @@ const Peering = () => { } else { eChartsRef?.getEchartsInstance().setOption(options); } - } + }, [eChartsRef, options]); function onClick(param, echarts) { if (param.data.value > 0) { diff --git a/ui/src/features/analysis/visualize/visualize.jsx b/ui/src/features/analysis/visualize/visualize.jsx index 44045d13..c067e0ec 100644 --- a/ui/src/features/analysis/visualize/visualize.jsx +++ b/ui/src/features/analysis/visualize/visualize.jsx @@ -332,13 +332,13 @@ const opt = { float: left; margin-right: 10px; } - + .gt50 { background-image: linear-gradient(90deg, ${usedColor} 50%, transparent 50%), linear-gradient(${deg}deg, white 50%, transparent 50%); } - + .lt50 { background-image: linear-gradient(${deg}deg, white 50%, transparent 50%), @@ -542,8 +542,7 @@ const Reset = (props) => { ); }; -const Search = React.forwardRef((props, ref) => { - const { options, setDataFocus } = props; +const Search = ({ ref, options, setDataFocus }) => { const [value, setValue] = React.useState(null); const [inputValue, setInputValue] = React.useState(''); @@ -620,7 +619,7 @@ const Search = React.forwardRef((props, ref) => { }} /> ); -}); +}; const Visualize = () => { const [options, setOptions] = React.useState(opt); @@ -656,6 +655,21 @@ const Visualize = () => { } }); + const selected = searchRef.current?.getValue?.() ?? null; + + if (selected) { + newOptions.title.show = false; + newOptions.legend.selectedMode = 'single'; + newOptions.legend.selected = Object.fromEntries( + newOptions.series.map(s => [s.name, s.name === selected]) + ); + } else { + newOptions.title.show = true; + newOptions.legend.selected = Object.fromEntries( + newOptions.series.map(s => [s.name, false]) + ); + } + setOptions(newOptions); setSearchOptions( newOptions.series.map((opt) => { @@ -665,7 +679,7 @@ const Visualize = () => { } }, [spaces, vnets, vhubs, endpoints, theme]); - function setDataFocus(target) { + const setDataFocus = React.useCallback((target) => { if(eChartsRef && !isEmpty(options.series)) { let newOptions = cloneDeep(options); @@ -689,7 +703,7 @@ const Visualize = () => { }); } } - } + }, [eChartsRef, options]); function resetView() { if(!searchRef.current.hasValue()) { diff --git a/ui/src/features/configure/associations/associations.jsx b/ui/src/features/configure/associations/associations.jsx index 20dc1136..0d0e0e97 100644 --- a/ui/src/features/configure/associations/associations.jsx +++ b/ui/src/features/configure/associations/associations.jsx @@ -1,236 +1,44 @@ import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; import { useLocation } from "react-router"; -import { styled } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles"; -import { isEmpty, isEqual, pickBy, orderBy, sortBy, pick } from "lodash"; +import { isEmpty, isEqual, sortBy, pick } from "lodash"; import { useSnackbar } from "notistack"; -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; +import { DataGrid } from "../../../global/grids"; import { Box, + CircularProgress, IconButton, - Menu, - MenuItem, - ListItemIcon, TextField, Autocomplete, Typography, Tooltip, - CircularProgress } from "@mui/material"; import { Refresh, SaveAlt, - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined } from "@mui/icons-material"; import { fetchBlockAvailable, - replaceBlockNetworks } from "../../ipam/ipamAPI"; import { selectSpaces, selectBlocks, selectSubscriptions, - fetchNetworksAsync, - selectViewSetting, - updateMeAsync, + replaceBlockNetworksAsync, getAdminStatus } from "../../ipam/ipamSlice"; const vNetPattern = "/Microsoft.Network/virtualNetworks/"; const vHubPattern = "/Microsoft.Network/virtualHubs/"; -const NetworkContext = React.createContext({}); - -const filterTypes = Object.assign({}, ReactDataGrid.defaultProps.filterTypes, { - array: { - name: 'array', - emptyValue: null, - operators: [ - { - name: 'contains', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? value.join(",").includes(filterValue) : true; - } - }, - { - name: 'notContains', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? !value.join(",").includes(filterValue) : true; - } - }, - { - name: 'eq', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? value.includes(filterValue) : true; - } - }, - { - name: 'neq', - fn: ({ value, filterValue, data }) => { - return filterValue !== (null || '') ? !value.includes(filterValue) : true; - } - } - ] - } -}); - -const Update = styled("span")(({ theme }) => ({ - fontWeight: 'bold', - color: theme.palette.error.light, - textShadow: '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white' -})); - -const gridStyle = { - height: '100%', - border: '1px solid rgba(224, 224, 224, 1)', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.useContext(NetworkContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); - - const viewSetting = useSelector(state => selectViewSetting(state, setting)); - - const onClick = () => { - setMenuOpen(prev => !prev); - } - - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } - - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } - - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } - - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) -} - const Associations = () => { const { enqueueSnackbar } = useSnackbar(); @@ -243,205 +51,49 @@ const Associations = () => { const [selectedBlock, setSelectedBlock] = React.useState(location.state?.block || null); const [prevBlock, setPrevBlock] = React.useState({}); - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); const [vNets, setVNets] = React.useState(null); - const [gridData, setGridData] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState(null); + const [initialSelection, setInitialSelection] = React.useState([]); + const [selectedRows, setSelectedRows] = React.useState([]); const [sending, setSending] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const [unchanged, setUnchanged] = React.useState(true); const isAdmin = useSelector(getAdminStatus); const spaces = useSelector(selectSpaces); const blocks = useSelector(selectBlocks); const subscriptions = useSelector(selectSubscriptions); - const viewSetting = useSelector(state => selectViewSetting(state, 'networks')); - - const saveTimer = React.useRef(); const dispatch = useDispatch(); const theme = useTheme(); + // Column definitions for AG Grid const columns = React.useMemo(() => [ - { name: "name", header: "Name", type: "string", flex: 1, visible: true }, + { field: "name", headerName: "Name", flex: 1 }, + { field: "type", headerName: "Type", flex: 0.45 }, + { field: "resource_group", headerName: "Resource Group", flex: 1 }, + { field: "subscription_name", headerName: "Subscription Name", flex: 1 }, + { field: "subscription_id", headerName: "Subscription ID", flex: 1, hide: true }, { - name: "type", - header: "Type", - type: "string", - flex: 0.45, - visible: true, - // filterEditor: SelectFilter, - // filterEditorProps: { - // multiple: true, - // wrapMultiple: false, - // dataSource: ['vNET', 'vHUB'].map(c => { - // return { id: c, label: c} - // }), - // } + field: "prefixes", + headerName: "Prefixes", + flex: 0.75, + valueGetter: (params) => { + const value = params.data?.prefixes; + return Array.isArray(value) ? value.join(', ') : ''; + }, + filterValueGetter: (params) => { + const value = params.data?.prefixes; + return Array.isArray(value) ? value.join(' ') : ''; + } }, - { name: "resource_group", header: "Resource Group", type: "string", flex: 1, visible: true }, - { name: "subscription_name", header: "Subscription Name", type: "string", flex: 1, visible: true }, - { name: "subscription_id", header: "Subscription ID", type: "string", flex: 1, visible: false }, - { name: "prefixes", header: "Prefixes", type: "array", flex: 0.75, render: ({value}) => value.join(", "), visible: true }, - { name: "id", header: () => , width: 25, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => "", visible: true } ], []); - const filterValue = [ - { name: "name", operator: "contains", type: "string", value: "" }, - // { name: "type", operator: "inlist", type: "select", value: ["vNET", "vHUB"] }, - { name: "type", operator: "contains", type: "string", value: "" }, - { name: "resource_group", operator: "contains", type: "string", value: "" }, - { name: "subscription_name", operator: "contains", type: "string", value: "" }, - { name: "subscription_id", operator: "contains", type: "string", value: "" }, - { name: "prefixes", operator: "contains", type: "array", value: "" } - ]; - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } - - var body = [ - { "op": "add", "path": `/views/networks`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body })); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; - - return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); - - const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState(null); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); - }, []); - - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); - - React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - vNets, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(vNets); - } - },[vNets, columnSortState]); - - React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); + // Row class rules for AG Grid (stale vs normal rows) + const rowClassRules = React.useMemo(() => ({ + 'ipam-block-vnet-stale': (params) => !params.data?.active, + 'ipam-block-vnet-normal': (params) => params.data?.active, + }), []); React.useEffect(() => { if (spaces) { @@ -477,7 +129,7 @@ const Associations = () => { setSelectedBlock(null); } } else { - setSelectionModel(null); + setSelectedRows([]); } } else { setSelectedBlock(null); @@ -492,44 +144,54 @@ const Associations = () => { } }, [selectedSpace, selectedBlock]); + // Check if selection has changed from original block vnets React.useEffect(() => { if(selectedBlock && vNets) { - setUnchanged(isEqual(selectedBlock['vnets'].reduce((obj, vnet) => (obj[vnet.id] = vnet, obj) ,{}), selectionModel)); + const blockVnets = Array.isArray(selectedBlock.vnets) ? selectedBlock.vnets : []; + const blockVnetIds = blockVnets.map(vnet => vnet.id).sort(); + const selectedIds = selectedRows.map(row => row.id).sort(); + setUnchanged(isEqual(blockVnetIds, selectedIds)); } else { setUnchanged(true); } - }, [vNets, selectedBlock, selectionModel]); + }, [vNets, selectedBlock, selectedRows]); const mockVNet = React.useCallback((id) => { - const nameRegex = "(?<=/virtualNetworks/).*"; - const rgRegex = "(?<=/resourceGroups/).*?(?=/)"; - const subRegex = "(?<=/subscriptions/).*?(?=/)"; - - const name = id.match(nameRegex)[0] - const resourceGroup = id.match(rgRegex)[0] - const subscription = id.match(subRegex)[0] - + const typeLookup = { virtualnetworks: 'vNET', virtualhubs: 'vHUB' }; + + const segments = id.split('/').filter(Boolean); + + const subscriptionIndex = segments.findIndex(segment => segment.toLowerCase() === 'subscriptions'); + const resourceGroupIndex = segments.findIndex(segment => segment.toLowerCase() === 'resourcegroups'); + + const subscription = subscriptionIndex > -1 ? segments[subscriptionIndex + 1] : null; + const resourceGroup = resourceGroupIndex > -1 ? segments[resourceGroupIndex + 1] : null; + + const providerIndex = segments.findIndex(segment => segment.toLowerCase() === 'microsoft.network'); + const typeSegment = providerIndex > -1 ? segments[providerIndex + 1] : null; + const nameSegment = providerIndex > -1 ? segments[providerIndex + 2] : null; + const mockNet = { - name: name, + name: nameSegment || id, id: id, - type: id.includes(vNetPattern) ? "vNET" : id.includes(vHubPattern) ? "vHUB" : "Unknown", + type: typeLookup[(typeSegment || '').toLowerCase()] || 'Unknown', prefixes: ["ErrNotFound"], subnets: [], - resource_group: resourceGroup.toLowerCase(), - subscription_name: subscriptions.find(sub => sub.subscription_id === subscription)?.name || 'Unknown', - subscription_id: subscription, + resource_group: resourceGroup ? resourceGroup.toLowerCase() : 'Unknown', + subscription_name: subscription ? subscriptions.find(sub => sub.subscription_id === subscription)?.name || 'Unknown' : 'Unknown', + subscription_id: subscription || 'Unknown', tenant_id: null, active: false }; - + return mockNet }, [subscriptions]); - const refreshData = React.useCallback(() => { + const refreshData = React.useCallback((silent = false) => { (async () => { if(selectedBlock) { try { - setRefreshing(true); + if (!silent) setRefreshing(true); var missing_data = []; var data = await fetchBlockAvailable(selectedBlock.parent_space, selectedBlock.name); @@ -540,7 +202,8 @@ const Associations = () => { item['active'] = true; }); - const missing = selectedBlock['vnets'].map(vnet => vnet.id).filter(item => !data.map(a => a.id.toLowerCase()).includes(item.toLowerCase())); + const blockVnets = Array.isArray(selectedBlock.vnets) ? selectedBlock.vnets : []; + const missing = blockVnets.map(vnet => vnet.id).filter(item => !data.map(a => a.id.toLowerCase()).includes(item.toLowerCase())); missing.forEach((item) => { missing_data.push(mockVNet(item)); @@ -550,21 +213,10 @@ const Associations = () => { setVNets(newVNetData); - setSelectionModel(prev => { - if(prev) { - const newSelection = {}; - - Object.keys(prev).forEach(key => { - if(newVNetData.map(vnet => vnet.id).includes(key)) { - newSelection[key] = prev[key]; - } - }); - - return newSelection; - } else { - return selectedBlock['vnets'].reduce((obj, vnet) => (obj[vnet.id] = vnet, obj) ,{}); - } - }); + // Set initial selection based on block vnets + const selection = newVNetData.filter(vnet => blockVnets.some(bv => bv.id === vnet.id)); + setInitialSelection(selection); + setSelectedRows(selection); } catch (e) { console.log("ERROR"); console.log("------------------"); @@ -572,7 +224,7 @@ const Associations = () => { console.log("------------------"); enqueueSnackbar("Error fetching available IP Block networks", { variant: "error" }); } finally { - setRefreshing(false); + if (!silent) setRefreshing(false); } } })(); @@ -582,9 +234,12 @@ const Associations = () => { (async () => { try { setSending(true); - await replaceBlockNetworks(selectedBlock.parent_space, selectedBlock.name, Object.keys(selectionModel)); + await dispatch(replaceBlockNetworksAsync({ + space: selectedBlock.parent_space, + block: selectedBlock.name, + body: selectedRows.map(row => row.id) + })).unwrap(); enqueueSnackbar("Successfully updated IP Block vNets", { variant: "success" }); - dispatch(fetchNetworksAsync()); } catch (e) { console.log("ERROR"); console.log("------------------"); @@ -612,63 +267,49 @@ const Associations = () => { if(isEqual(prevBlock.identity, newBlock.identity)) { if(!isEqual(prevBlock.data, newBlock.data)) { - refreshData(); + refreshData(true); setPrevBlock(newBlock); } } else { - setSelectionModel(null); - setVNets(null); + setInitialSelection([]); + setSelectedRows([]); + setVNets([]); + setRefreshing(true); refreshData(); setPrevBlock(newBlock); } } if(!selectedBlock && !isEmpty(prevBlock)) { - setSelectionModel(null); - setVNets(null); + setInitialSelection([]); + setSelectedRows([]); + setVNets([]); setPrevBlock({}); } }, [selectedBlock, subscriptions, prevBlock, refreshData]); - function setSelection(data) { - const newData = Object.entries(data).reduce((acc, [key, value]) => { - const n = { - id: value.id, - active: value.active - }; - - acc[key] = n; - - return acc; - }, {}); - - setSelectionModel(newData); - } - - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); + // Handle selection changes from the grid + const handleSelectionChanged = React.useCallback((rows) => { + if (isAdmin) { + setSelectedRows(rows); + } + }, [isAdmin]); - function NoRowsOverlay() { + // No rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( - - { selectedBlock - ? - No Virtual Networks Found for Selected Block CIDR - - : - Please Select a Space & Block - - } - + + + { selectedBlock + ? "No Virtual Networks Found for Selected Block CIDR" + : "Please Select a Space & Block" + } + + ); - } + }, [selectedBlock]); return ( - @@ -791,7 +432,7 @@ const Associations = () => { { (sending || !subscriptions || !spaces || !blocks || !vNets || refreshing ) ? (...) : - ({Object.keys(selectionModel).length}/{vNets ? vNets.length : '?'}) + ({selectedRows.length}/{vNets ? vNets.length : '?'}) } @@ -823,7 +464,7 @@ const Associations = () => { refreshData()} disabled={sending || refreshing || !selectedSpace || !selectedBlock } > @@ -837,63 +478,30 @@ const Associations = () => { sx={{ pt: 4, height: "100%", - '& .ipam-block-vnet-stale': { - background: theme.palette.mode === 'dark' ? 'rgb(220, 20, 20) !important' : 'rgb(255, 230, 230) !important', - '.InovuaReactDataGrid__row-hover-target': { - '&:hover': { - background: theme.palette.mode === 'dark' ? 'rgb(220, 100, 100) !important' : 'rgb(255, 220, 220) !important', - } - } + // Stale row styling (vNets no longer present) + // Override selection background to prevent AG Grid's blue tint + '& .ag-row.ipam-block-vnet-stale': { + '--ag-selected-row-background-color': theme.palette.mode === 'dark' ? 'rgb(120, 40, 40)' : 'rgb(255, 210, 210)', + backgroundColor: theme.palette.mode === 'dark' ? 'rgb(120, 40, 40) !important' : 'rgb(255, 210, 210) !important', }, - '& .ipam-block-vnet-normal': { - background: theme.palette.mode === 'dark' ? 'rgb(49, 57, 67)' : 'white', - '.InovuaReactDataGrid__row-hover-target': { - '&:hover': { - background: theme.palette.mode === 'dark' ? 'rgb(74, 84, 115) !important' : 'rgb(208, 213, 237) !important', - } - } - } }} > - Updating : "Loading"} - dataSource={gridData || []} - selected={selectionModel || []} - onSelectionChange={({selected}) => isAdmin && setSelection(selected)} - rowClassName={({data}) => `ipam-block-vnet-${!data.active ? 'stale' : 'normal'}`} - onCellDoubleClick={onCellDoubleClick} - sortInfo={columnSortState} - filterTypes={filterTypes} - defaultFilterValue={filterValue} - emptyText={NoRowsOverlay} - style={gridStyle} + checkboxSelect={isAdmin} + isLoading={sending || refreshing} + initialSelectedRows={initialSelection} + onRowSelectionChanged={handleSelectionChanged} + rowClassRules={rowClassRules} + noRowsOverlay={NoRowsOverlay} /> - ); } diff --git a/ui/src/features/configure/basics/basics.jsx b/ui/src/features/configure/basics/basics.jsx index 892be546..4eca2ba5 100644 --- a/ui/src/features/configure/basics/basics.jsx +++ b/ui/src/features/configure/basics/basics.jsx @@ -36,7 +36,7 @@ const TopSection = styled("div")(({ theme }) => ({ height: "50%", width: "100%", border: "1px solid rgba(224, 224, 224, 1)", - borderRadius: "4px", + // borderRadius: "4px", marginBottom: theme.spacing(1.5) })); @@ -46,7 +46,7 @@ const BottomSection = styled("div")(({ theme }) => ({ height: "50%", width: "100%", border: "1px solid rgba(224, 224, 224, 1)", - borderRadius: "4px", + // borderRadius: "4px", marginTop: theme.spacing(1.5) })); diff --git a/ui/src/features/configure/basics/block/block.jsx b/ui/src/features/configure/basics/block/block.jsx index 0afd07d1..aeab7517 100644 --- a/ui/src/features/configure/basics/block/block.jsx +++ b/ui/src/features/configure/basics/block/block.jsx @@ -6,11 +6,7 @@ import { useNavigate } from "react-router"; import { isEmpty} from "lodash"; -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; - -import { useTheme } from "@mui/material/styles"; +import { ConfigureGrid } from "../../../../global/grids"; import { Box, @@ -42,14 +38,14 @@ import { BasicContext } from "../basicContext"; import { getAdminStatus } from "../../../ipam/ipamSlice"; const GridHeader = styled("div")({ - height: "50px", + height: "35px", width: "100%", display: "flex", borderBottom: "1px solid rgba(224, 224, 224, 1)", }); const GridTitle = styled("div")(({ theme }) => ({ - ...theme.typography.h6, + ...theme.typography.button, width: "80%", textAlign: "center", alignSelf: "center", @@ -60,12 +56,6 @@ const GridBody = styled("div")({ width: "100%", }); -const gridStyle = { - height: '100%', - border: 'none', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - const columns = [ { name: "name", header: "Name", defaultFlex: 1 }, { name: "parent_space", header: "Parent Space", defaultFlex: 1 }, @@ -77,7 +67,6 @@ export default function BlockDataGrid(props) { const { blocks, refreshing, refresh } = React.useContext(BasicContext); const [previousSpace, setPreviousSpace] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState({}); const [addBlockOpen, setAddBlockOpen] = React.useState(false); const [editBlockOpen, setEditBlockOpen] = React.useState(false); @@ -90,43 +79,45 @@ export default function BlockDataGrid(props) { const navigate = useNavigate(); - const theme = useTheme(); - const menuOpen = Boolean(anchorEl); + // Clear selection when space changes const onSpaceChange = React.useCallback(() => { if(selectedSpace) { if(selectedSpace.name !== previousSpace) { - setSelectionModel({}); + setSelectedBlock(null); } } setPreviousSpace(selectedSpace ? selectedSpace.name : null); - }, [selectedSpace, previousSpace]); + }, [selectedSpace, previousSpace, setSelectedBlock]); React.useEffect(() => { onSpaceChange() }, [selectedSpace, onSpaceChange]); - React.useEffect(() => { - if(!isEmpty(selectionModel)) { - setSelectedBlock(Object.values(selectionModel)[0]) - } else { - setSelectedBlock(null); - } - }, [selectionModel, setSelectedBlock]); - + // Sync selection when blocks data changes React.useEffect(() => { if(blocks && selectedBlock && selectedSpace) { const currentBlock = blocks.find(block => (block.name === selectedBlock.name) && (block.parent_space === selectedSpace.name)); - + if(!currentBlock) { - setSelectionModel({}); + setSelectedBlock(null); } else { setSelectedBlock(currentBlock); } } - }, [blocks, selectedSpace, selectedBlock, setSelectedBlock, setSelectionModel]); + }, [blocks, selectedSpace, selectedBlock, setSelectedBlock]); + + // Handle row click from ConfigureGrid + const handleRowClick = React.useCallback((data) => { + // Toggle selection: if clicking the same row, deselect; otherwise select new row + if (selectedBlock && selectedBlock.name === data.name) { + setSelectedBlock(null); + } else { + setSelectedBlock(data); + } + }, [selectedBlock, setSelectedBlock]); const handleMenuClick = (event) => { setAnchorEl(event.currentTarget); @@ -151,20 +142,8 @@ export default function BlockDataGrid(props) { setDeleteBlockOpen(true); }; - function onClick(data) { - var id = data.name; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - } - - return newSelectionModel; - }); - } - - function NoRowsOverlay() { + // Custom no rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( { selectedSpace @@ -177,7 +156,12 @@ export default function BlockDataGrid(props) { } ); - } + }, [selectedSpace]); + + // Compute row data for the grid + const rowData = React.useMemo(() => { + return selectedSpace ? blocks.filter((block) => block.parent_space === selectedSpace.name) : []; + }, [selectedSpace, blocks]); return ( @@ -331,22 +315,13 @@ export default function BlockDataGrid(props) { - block.parent_space === selectedSpace.name) : []} - onRowClick={(rowData) => onClick(rowData.data)} - selected={selectionModel} - emptyText={NoRowsOverlay} - style={gridStyle} + noRowsOverlay={NoRowsOverlay} /> diff --git a/ui/src/features/configure/basics/block/utils/addBlock.jsx b/ui/src/features/configure/basics/block/utils/addBlock.jsx index cb771f5c..f8b9bd04 100644 --- a/ui/src/features/configure/basics/block/utils/addBlock.jsx +++ b/ui/src/features/configure/basics/block/utils/addBlock.jsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -14,11 +14,8 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { createBlockAsync } from "../../../../ipam/ipamSlice"; import { @@ -26,21 +23,6 @@ import { CIDR_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function AddBlock(props) { const { open, handleClose, space, blocks } = props; @@ -193,13 +175,13 @@ export default function AddBlock(props) { - Create - + diff --git a/ui/src/features/configure/basics/block/utils/confirmDelete.jsx b/ui/src/features/configure/basics/block/utils/confirmDelete.jsx index 8bed53e9..dc81ff9f 100644 --- a/ui/src/features/configure/basics/block/utils/confirmDelete.jsx +++ b/ui/src/features/configure/basics/block/utils/confirmDelete.jsx @@ -4,7 +4,7 @@ import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -17,11 +17,8 @@ import { DialogActions, DialogContent, DialogContentText, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { deleteBlockAsync } from "../../../../ipam/ipamSlice"; const Spotlight = styled("span")(({ theme }) => ({ @@ -29,21 +26,6 @@ const Spotlight = styled("span")(({ theme }) => ({ color: theme.palette.mode === 'dark' ? 'cornflowerblue' : 'mediumblue' })); -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function ConfirmDelete(props) { const { open, handleClose, space, block } = props; @@ -102,7 +84,7 @@ export default function ConfirmDelete(props) { - Please confirm you want to delete Block '{block}' + Please confirm you want to delete Block {`'${block}'`} @@ -122,13 +104,13 @@ export default function ConfirmDelete(props) { - Delete - + diff --git a/ui/src/features/configure/basics/block/utils/editBlock.jsx b/ui/src/features/configure/basics/block/utils/editBlock.jsx index 299e207a..fa62dd81 100644 --- a/ui/src/features/configure/basics/block/utils/editBlock.jsx +++ b/ui/src/features/configure/basics/block/utils/editBlock.jsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -14,11 +14,8 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { updateBlockAsync } from "../../../../ipam/ipamSlice"; import { @@ -26,21 +23,6 @@ import { CIDR_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function EditBlock(props) { const { open, handleClose, space, blocks, block } = props; @@ -223,13 +205,13 @@ export default function EditBlock(props) { - Update - + diff --git a/ui/src/features/configure/basics/space/space.jsx b/ui/src/features/configure/basics/space/space.jsx index 1b7ba1a7..c56055a3 100644 --- a/ui/src/features/configure/basics/space/space.jsx +++ b/ui/src/features/configure/basics/space/space.jsx @@ -2,13 +2,7 @@ import * as React from "react"; import { useSelector } from "react-redux"; import { styled } from "@mui/material/styles"; -import { isEmpty } from "lodash"; - -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; - -import { useTheme } from "@mui/material/styles"; +import { ConfigureGrid } from "../../../../global/grids"; import { Box, @@ -39,14 +33,14 @@ import { BasicContext } from "../basicContext"; import { getAdminStatus } from "../../../ipam/ipamSlice"; const GridHeader = styled("div")({ - height: "50px", + height: "35px", width: "100%", display: "flex", borderBottom: "1px solid rgba(224, 224, 224, 1)", }); const GridTitle = styled("div")(({ theme }) => ({ - ...theme.typography.h6, + ...theme.typography.button, width: "80%", textAlign: "center", alignSelf: "center", @@ -57,12 +51,6 @@ const GridBody = styled("div")({ width: "100%", }); -const gridStyle = { - height: '100%', - border: 'none', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - const columns = [ { name: "name", header: "Name", defaultFlex: 0.5 }, { name: "desc", header: "Description", defaultFlex: 1 }, @@ -72,8 +60,6 @@ export default function SpaceDataGrid(props) { const { selectedSpace, setSelectedSpace, setSelectedBlock } = props; const { spaces, refresh } = React.useContext(BasicContext); - const [selectionModel, setSelectionModel] = React.useState({}); - const [addSpaceOpen, setAddSpaceOpen] = React.useState(false); const [editSpaceOpen, setEditSpaceOpen] = React.useState(false); const [deleteSpaceOpen, setDeleteSpaceOpen] = React.useState(false); @@ -82,27 +68,33 @@ export default function SpaceDataGrid(props) { const isAdmin = useSelector(getAdminStatus); - const theme = useTheme(); - const menuOpen = Boolean(anchorEl); - React.useEffect(() => { - setSelectedBlock(null); - setSelectedSpace(!isEmpty(selectionModel) ? Object.values(selectionModel)[0] : null); - }, [selectionModel, setSelectedSpace, setSelectedBlock]); - + // Sync selection when spaces data changes React.useEffect(() => { if(spaces && selectedSpace) { const currentSpace = spaces.find(space => space.name === selectedSpace.name); - + if(!currentSpace) { - setSelectedBlock(null) - setSelectionModel({}); + setSelectedBlock(null); + setSelectedSpace(null); } else { setSelectedSpace(currentSpace); } } - }, [spaces, selectedSpace, setSelectedSpace, setSelectedBlock, setSelectionModel]); + }, [spaces, selectedSpace, setSelectedSpace, setSelectedBlock]); + + // Handle row click from ConfigureGrid + const handleRowClick = React.useCallback((data) => { + // Toggle selection: if clicking the same row, deselect; otherwise select new row + if (selectedSpace && selectedSpace.name === data.name) { + setSelectedBlock(null); + setSelectedSpace(null); + } else { + setSelectedBlock(null); + setSelectedSpace(data); + } + }, [selectedSpace, setSelectedSpace, setSelectedBlock]); const handleMenuClick = (event) => { setAnchorEl(event.currentTarget); @@ -127,22 +119,8 @@ export default function SpaceDataGrid(props) { setDeleteSpaceOpen(true); }; - function onClick(data) { - var id = data.name; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - } else { - setSelectedBlock(null); - } - - return newSelectionModel; - }); - } - - function NoRowsOverlay() { + // Custom no rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( @@ -150,7 +128,7 @@ export default function SpaceDataGrid(props) { ); - } + }, []); return ( @@ -277,23 +255,14 @@ export default function SpaceDataGrid(props) { - onClick(rowData.data)} - selected={selectionModel} - emptyText={NoRowsOverlay} - style={gridStyle} + noRowsOverlay={NoRowsOverlay} + isLoading={!spaces} /> diff --git a/ui/src/features/configure/basics/space/utils/addSpace.jsx b/ui/src/features/configure/basics/space/utils/addSpace.jsx index 0210ddfa..86965ba9 100644 --- a/ui/src/features/configure/basics/space/utils/addSpace.jsx +++ b/ui/src/features/configure/basics/space/utils/addSpace.jsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -14,11 +14,8 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { createSpaceAsync } from "../../../../ipam/ipamSlice"; import { @@ -26,21 +23,6 @@ import { SPACE_DESC_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function AddSpace(props) { const { open, handleClose, spaces } = props; @@ -198,13 +180,13 @@ export default function AddSpace(props) { - Create - + diff --git a/ui/src/features/configure/basics/space/utils/confirmDelete.jsx b/ui/src/features/configure/basics/space/utils/confirmDelete.jsx index bd4b89ab..4f943a35 100644 --- a/ui/src/features/configure/basics/space/utils/confirmDelete.jsx +++ b/ui/src/features/configure/basics/space/utils/confirmDelete.jsx @@ -4,7 +4,7 @@ import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -17,11 +17,8 @@ import { DialogActions, DialogContent, DialogContentText, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { deleteSpaceAsync } from "../../../../ipam/ipamSlice"; const Spotlight = styled("span")(({ theme }) => ({ @@ -29,21 +26,6 @@ const Spotlight = styled("span")(({ theme }) => ({ color: theme.palette.mode === 'dark' ? 'cornflowerblue' : 'mediumblue' })); -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function ConfirmDelete(props) { const { open, handleClose, space } = props; @@ -103,7 +85,7 @@ export default function ConfirmDelete(props) { - Please confirm you want to delete Space '{space}' + Please confirm you want to delete Space {`'${space}'`} @@ -123,13 +105,13 @@ export default function ConfirmDelete(props) { - Delete - + diff --git a/ui/src/features/configure/basics/space/utils/editSpace.jsx b/ui/src/features/configure/basics/space/utils/editSpace.jsx index 6ac73cfd..ad9f3539 100644 --- a/ui/src/features/configure/basics/space/utils/editSpace.jsx +++ b/ui/src/features/configure/basics/space/utils/editSpace.jsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from 'react-draggable'; +import DraggablePaper from '../../../../../global/DraggablePaper'; import { Box, @@ -14,11 +14,8 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { updateSpaceAsync } from "../../../../ipam/ipamSlice"; import { @@ -26,21 +23,6 @@ import { SPACE_DESC_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function EditSpace(props) { const { open, handleClose, space, spaces } = props; @@ -226,13 +208,13 @@ export default function EditSpace(props) { - Update - + diff --git a/ui/src/features/configure/externals/externals.jsx b/ui/src/features/configure/externals/externals.jsx index 35e9733a..340188cd 100644 --- a/ui/src/features/configure/externals/externals.jsx +++ b/ui/src/features/configure/externals/externals.jsx @@ -140,14 +140,14 @@ export default function Externals() { } } else { setSelectedBlock(null); - setExternals(null); + setExternals([]); } } else { - setExternals(null); + setExternals([]); } } else { setSelectedBlock(null); - setExternals(null); + setExternals([]); } }, [blocks, selectedBlock]); @@ -174,7 +174,7 @@ export default function Externals() { } } else { setSelectedExternal(null); - setSubnets(null); + setSubnets([]); } }, [externals, selectedExternal]); @@ -215,7 +215,7 @@ export default function Externals() { (option, value) => { const newOption = pick(option, ['name']); const newValue = pick(value, ['name']); - + return isEqual(newOption, newValue); } } @@ -267,7 +267,7 @@ export default function Externals() { (option, value) => { const newOption = pick(option, ['id', 'name']); const newValue = pick(value, ['id', 'name']); - + return isEqual(newOption, newValue); } } diff --git a/ui/src/features/configure/externals/networks/networks.jsx b/ui/src/features/configure/externals/networks/networks.jsx index dedec8d0..736104b0 100644 --- a/ui/src/features/configure/externals/networks/networks.jsx +++ b/ui/src/features/configure/externals/networks/networks.jsx @@ -1,41 +1,22 @@ import * as React from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; -import { isEmpty, pickBy, orderBy, cloneDeep } from "lodash"; +import { cloneDeep } from "lodash"; -import { useSnackbar } from "notistack"; - -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; +import { DataGrid } from "../../../../global/grids"; import { Box, - IconButton, - Menu, - MenuItem, - ListItemIcon, Typography, - CircularProgress, - Divider } from "@mui/material"; import { - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined, AddOutlined, EditOutlined, DeleteOutline } from "@mui/icons-material"; import { - selectViewSetting, - updateMeAsync, getAdminStatus } from "../../../ipam/ipamSlice"; @@ -45,200 +26,6 @@ import DeleteExtNetwork from "./utils/deleteNetwork"; import { ExternalContext } from "../externalContext"; -const ExtNetworkContext = React.createContext({}); - -const gridStyle = { - height: '100%', - border: '1px solid rgba(224, 224, 224, 1)', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { - selectedSpace, - selectedBlock, - selectedExternal, - setAddExtOpen, - setEditExtOpen, - setDelExtOpen, - saving, - sendResults, - saveConfig, - loadConfig, - resetConfig - } = React.useContext(ExtNetworkContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); - - const isAdmin = useSelector(getAdminStatus); - const viewSetting = useSelector(state => selectViewSetting(state, setting)); - - const onClick = () => { - setMenuOpen(prev => !prev); - } - - const onAddExt = () => { - setAddExtOpen(true); - setMenuOpen(false); - } - - const onEditExt = () => { - setEditExtOpen(true); - setMenuOpen(false); - } - - const onDelExt = () => { - setDelExtOpen(true); - setMenuOpen(false); - } - - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } - - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } - - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } - - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - - - Add Network - - - - - - Edit Network - - - - - - Delete Network - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) -} - const Networks = (props) => { const { selectedSpace, @@ -250,247 +37,90 @@ const Networks = (props) => { } = props; const { refreshing } = React.useContext(ExternalContext); - const { enqueueSnackbar } = useSnackbar(); - - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); - const [gridData, setGridData] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState({}); - - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const [addExtOpen, setAddExtOpen] = React.useState(false); const [editExtOpen, setEditExtOpen] = React.useState(false); const [delExtOpen, setDelExtOpen] = React.useState(false); const isAdmin = useSelector(getAdminStatus); - const viewSetting = useSelector(state => selectViewSetting(state, 'extnetworks')); - - const saveTimer = React.useRef(); - - const dispatch = useDispatch(); - const theme = useTheme(); + // Column definitions for AG Grid const columns = React.useMemo(() => [ - { name: "name", header: "Name", type: "string", flex: 0.5, draggable: false, visible: true }, - { name: "desc", header: "Description", type: "string", flex: 1, draggable: false, visible: true }, - { name: "cidr", header: "CIDR", type: "string", flex: 0.30, draggable: false, visible: true }, - { name: "id", header: () => , width: 25, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => "", visible: true } + { field: "name", headerName: "Name", flex: 0.5 }, + { field: "desc", headerName: "Description", flex: 1 }, + { field: "cidr", headerName: "CIDR", flex: 0.30 }, ], []); - const filterValue = [ - { name: "name", operator: "contains", type: "string", value: "" }, - { name: "desc", operator: "contains", type: "string", value: "" }, - { name: "cidr", operator: "contains", type: "string", value: "" } - ]; - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } - - var body = [ - { "op": "add", "path": `/views/extnetworks`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body })); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; + // Extra menu items for Add/Edit/Delete actions + const extraMenuItems = React.useMemo(() => [ + { + icon: AddOutlined, + label: "Add Network", + onClick: () => setAddExtOpen(true), + disabled: !selectedSpace || !selectedBlock || !isAdmin, + }, + { + icon: EditOutlined, + label: "Edit Network", + onClick: () => setEditExtOpen(true), + disabled: !selectedExternal || !isAdmin, + }, + { + icon: DeleteOutline, + label: "Delete Network", + onClick: () => setDelExtOpen(true), + disabled: !selectedExternal || !isAdmin, + }, + ], [selectedSpace, selectedBlock, selectedExternal, isAdmin]); + + // Handle row selection change + const handleRowSelectionChanged = React.useCallback((row) => { + setSelectedExternal(row); + }, [setSelectedExternal]); + + // Derive grid data synchronously from selectedBlock to prevent + // a flash of the no-rows overlay between selection and data display. + const gridData = React.useMemo(() => { + if (!selectedBlock) return []; + + const newExternals = cloneDeep(selectedBlock['externals']); + + return newExternals.reduce((acc, curr) => { + curr['id'] = `${selectedSpace}@${selectedBlock.name}@${curr.name}}`; + acc.push(curr); return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); - - const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState(null); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); - }, []); - - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); + }, []); + }, [selectedSpace, selectedBlock]); + // Sync enriched externals data to parent state (for dialogs, selection tracking, etc.) React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - externals, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(externals); - } - },[externals, columnSortState]); + setExternals(gridData); + }, [gridData, setExternals]); + // Clear selection when externals change React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); - - function onClick(data) { - var id = data.id; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - } - - return newSelectionModel; - }); - } - - React.useEffect(() => { - if(selectedBlock) { - var newExternals = cloneDeep(selectedBlock['externals']); - - const newData = newExternals.reduce((acc, curr) => { - curr['id'] = `${selectedSpace}@${selectedBlock.name}@${curr.name}}` - - acc.push(curr); - - return acc; - }, []); - - setExternals(newData); - } else { - setExternals(null); - } - }, [selectedSpace, selectedBlock, setExternals]); - - React.useEffect(() => { - if(Object.keys(selectionModel).length > 0) { - setSelectedExternal(Object.values(selectionModel)[0]); - } else { + if (!externals || externals.length === 0) { setSelectedExternal(null); } - }, [selectionModel, setSelectedExternal]); + }, [externals, setSelectedExternal]); - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - - function NoRowsOverlay() { + // No rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( - - { selectedBlock - ? - No External Networks Found for Selected Block - - : - Please Select a Space & Block - - } - + + + {selectedBlock + ? "No External Networks Found for Selected Block" + : "Please Select a Space & Block" + } + + ); - } + }, [selectedBlock]); return ( - { isAdmin && + {isAdmin && { /> } - - - - - External Networks - - - - onClick(rowData.data)} - onCellDoubleClick={onCellDoubleClick} - selected={selectionModel} - emptyText={NoRowsOverlay} - style={gridStyle} - /> - + + + + External Networks + + + + - + ); } diff --git a/ui/src/features/configure/externals/networks/utils/addNetwork.jsx b/ui/src/features/configure/externals/networks/utils/addNetwork.jsx index 44c9652d..3860e25c 100644 --- a/ui/src/features/configure/externals/networks/utils/addNetwork.jsx +++ b/ui/src/features/configure/externals/networks/utils/addNetwork.jsx @@ -3,12 +3,12 @@ import { useSelector, useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, Button, - Radio, + Divider, Tooltip, TextField, Autocomplete, @@ -16,10 +16,12 @@ import { DialogTitle, DialogActions, DialogContent, - Paper + ToggleButton, + ToggleButtonGroup, + Typography, } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { selectNetworks, @@ -38,27 +40,13 @@ import { cidrMasks } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function AddExtNetwork(props) { const { open, handleClose, space, block, externals } = props; const { enqueueSnackbar } = useSnackbar(); - const [addBySize, setAddBySize] = React.useState(true); + const [mode, setMode] = React.useState("auto"); + const addBySize = mode === "auto"; const [maskOptions, setMaskOptions] = React.useState(null); const [maskInput, setMaskInput] = React.useState(''); @@ -74,6 +62,10 @@ export default function AddExtNetwork(props) { const networks = useSelector(selectNetworks); + function onModeChange(_, newMode) { + if (newMode !== null) setMode(newMode); + } + function onCancel() { handleClose(); @@ -83,7 +75,7 @@ export default function AddExtNetwork(props) { setSelectedMask(maskOptions.length >= 1 ? maskOptions[1] : maskOptions[0]); - setAddBySize(true); + setMode("auto"); } function onSubmit() { @@ -203,8 +195,7 @@ export default function AddExtNetwork(props) { } const hasError = React.useMemo(() => { - var emptyCheck = false; - var errorCheck = false; + let emptyCheck, errorCheck; if (addBySize) { errorCheck = (extName.error || extDesc.error); @@ -233,100 +224,109 @@ export default function AddExtNetwork(props) { }, [block]); return ( -
- - - Add External Network - - - - - - Network name must be unique -
- Max of 64 characters -
- Can contain alphnumerics -
- Can contain underscore, hypen and period -
- Cannot start/end with underscore, hypen or period - - } + + + Add External Network + + + + + - Network name must be unique +
- Max of 64 characters +
- Can contain alphnumerics +
- Can contain underscore, hypen and period +
- Cannot start/end with underscore, hypen or period + + } + > + onNameChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "80%" }} + /> +
+ + - Max of 128 characters +
- Can contain alphnumerics +
- Can contain spaces +
- Can contain underscore, hypen, slash and period +
- Cannot start/end with underscore, hypen, slash or period + + } + > + onDescChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "80%" }} + /> +
+ + - onNameChange(event)} - inputProps={{ spellCheck: false }} - sx={{ width: "80%" }} - /> -
- - - Max of 128 characters -
- Can contain alphnumerics -
- Can contain spaces -
- Can contain underscore, hypen, slash and period -
- Cannot start/end with underscore, hypen, slash or period - - } + Allocation Mode + + - onDescChange(event)} - inputProps={{ spellCheck: false }} - sx={{ width: "80%" }} - /> -
- - - setAddBySize(true)} - name="add-by-size" - sx={{ pl: 0 }} - /> - - + Auto + Manual + + + + + {mode === "auto" ? ( + option.name} inputValue={maskInput} - onInputChange={(event, newInputValue) => setMaskInput(newInputValue)} + onInputChange={(_, newInputValue) => setMaskInput(newInputValue)} value={selectedMask} - onChange={(event, newValue) => setSelectedMask(newValue)} - sx={{ width: '6ch' }} + onChange={(_, newValue) => setSelectedMask(newValue)} + sx={{ width: '7ch' }} ListboxProps={{ - style: { - maxHeight: "15rem" - }, + style: { maxHeight: "15rem" }, position: "bottom-start" }} renderInput={(params) => ( @@ -339,15 +339,8 @@ export default function AddExtNetwork(props) { )} /> - - setAddBySize(false)} - name="add-by-cidr" - sx={{ pl: 0 }} - /> - - + ) : ( + - Must be in valid CIDR notation format -
    - Example: 1.2.3.4/5 +
- Example: 1.2.3.4/5
- Cannot overlap existing subnets } > 0 && extCidr.error) } + autoFocus + error={extCidr.value.length > 0 && extCidr.error} margin="dense" - id="name" + id="cidr" label="CIDR" - type="cidr" + placeholder="x.x.x.x/x" variant="standard" value={extCidr.value} onChange={(event) => onCidrChange(event)} inputProps={{ spellCheck: false }} - sx={{ - width: "100%", - pointerEvents: addBySize ? 'none' : 'auto' - }} + sx={{ width: "20ch" }} />
-
+ )}
-
- - - - Add - - -
-
+ + + + + + + ); } diff --git a/ui/src/features/configure/externals/networks/utils/deleteNetwork.jsx b/ui/src/features/configure/externals/networks/utils/deleteNetwork.jsx index ce460b28..6108ac5f 100644 --- a/ui/src/features/configure/externals/networks/utils/deleteNetwork.jsx +++ b/ui/src/features/configure/externals/networks/utils/deleteNetwork.jsx @@ -4,7 +4,7 @@ import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -17,10 +17,9 @@ import { DialogActions, DialogContent, DialogContentText, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { deleteBlockExternalAsync } from "../../../../ipam/ipamSlice"; @@ -29,21 +28,6 @@ const Spotlight = styled("span")(({ theme }) => ({ color: theme.palette.mode === 'dark' ? 'cornflowerblue' : 'mediumblue' })); -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function DeleteExtNetwork(props) { const { open, handleClose, space, block, external } = props; @@ -102,7 +86,7 @@ export default function DeleteExtNetwork(props) { - Please confirm you want to delete External Network '{external}' + Please confirm you want to delete External Network {`'${external}'`} @@ -122,13 +106,13 @@ export default function DeleteExtNetwork(props) { - Delete - + diff --git a/ui/src/features/configure/externals/networks/utils/editNetwork.jsx b/ui/src/features/configure/externals/networks/utils/editNetwork.jsx index e53ca9c0..4a269eb9 100644 --- a/ui/src/features/configure/externals/networks/utils/editNetwork.jsx +++ b/ui/src/features/configure/externals/networks/utils/editNetwork.jsx @@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -14,10 +14,9 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { selectNetworks, @@ -35,21 +34,6 @@ import { CIDR_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function EditExtNetwork(props) { const { open, handleClose, space, block, externals, selectedExternal } = props; @@ -223,11 +207,8 @@ export default function EditExtNetwork(props) { }, [selectedExternal, extName, extDesc, extCidr]); const hasError = React.useMemo(() => { - var emptyCheck = false; - var errorCheck = false; - - errorCheck = (extName.error || extDesc.error || extCidr.error); - emptyCheck = (extName.value.length === 0 || extDesc.value.length === 0 || extCidr.value.length === 0); + const errorCheck = (extName.error || extDesc.error || extCidr.error); + const emptyCheck = (extName.value.length === 0 || extDesc.value.length === 0 || extCidr.value.length === 0); return (errorCheck || emptyCheck); }, [extName, extDesc, extCidr]); @@ -349,13 +330,13 @@ export default function EditExtNetwork(props) { > Cancel - Update - + diff --git a/ui/src/features/configure/externals/subnets/subnets.jsx b/ui/src/features/configure/externals/subnets/subnets.jsx index 7b301b4e..5251bf7d 100644 --- a/ui/src/features/configure/externals/subnets/subnets.jsx +++ b/ui/src/features/configure/externals/subnets/subnets.jsx @@ -1,33 +1,16 @@ import * as React from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; -import { isEmpty, pickBy, orderBy, cloneDeep } from "lodash"; +import { cloneDeep } from "lodash"; -import { useSnackbar } from "notistack"; - -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; +import { DataGrid } from "../../../../global/grids"; import { Box, - IconButton, - Menu, - MenuItem, - ListItemIcon, Typography, - CircularProgress, - Divider } from "@mui/material"; import { - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined, AddOutlined, EditOutlined, DeleteOutline, @@ -35,8 +18,6 @@ import { } from "@mui/icons-material"; import { - selectViewSetting, - updateMeAsync, getAdminStatus } from "../../../ipam/ipamSlice"; @@ -47,214 +28,6 @@ import ManageExtEndpoints from "./utils/manageEndpoints"; import { ExternalContext } from "../externalContext"; -const ExtSubnetContext = React.createContext({}); - -const gridStyle = { - height: '100%', - border: '1px solid rgba(224, 224, 224, 1)', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { - selectedExternal, - selectedSubnet, - setAddExtSubOpen, - setEditExtSubOpen, - setDelExtSubOpen, - setManExtEndOpen, - saving, - sendResults, - saveConfig, - loadConfig, - resetConfig - } = React.useContext(ExtSubnetContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); - - const isAdmin = useSelector(getAdminStatus); - const viewSetting = useSelector(state => selectViewSetting(state, setting)); - - const onClick = () => { - setMenuOpen(prev => !prev); - } - - const onAddExtSub = () => { - setAddExtSubOpen(true); - setMenuOpen(false); - } - - const onEditExtSub = () => { - setEditExtSubOpen(true); - setMenuOpen(false); - } - - const onDelExtSub = () => { - setDelExtSubOpen(true); - setMenuOpen(false); - } - - const onManExtEnd = () => { - setManExtEndOpen(true); - setMenuOpen(false); - } - - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } - - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } - - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } - - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - - - Add Subnet - - - - - - Edit Subnet - - - - - - Remove Subnet - - - - - - Manage Endpoints - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) -} - const Subnets = (props) => { const { selectedSpace, @@ -267,248 +40,97 @@ const Subnets = (props) => { } = props; const { refreshing } = React.useContext(ExternalContext); - const { enqueueSnackbar } = useSnackbar(); - - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); - const [gridData, setGridData] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState({}); - - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const [addExtSubOpen, setAddExtSubOpen] = React.useState(false); const [editExtSubOpen, setEditExtSubOpen] = React.useState(false); const [delExtSubOpen, setDelExtSubOpen] = React.useState(false); const [manExtEndOpen, setManExtEndOpen] = React.useState(false); const isAdmin = useSelector(getAdminStatus); - const viewSetting = useSelector(state => selectViewSetting(state, 'extsubnets')); - - const saveTimer = React.useRef(); - - const dispatch = useDispatch(); - const theme = useTheme(); + // Column definitions for AG Grid const columns = React.useMemo(() => [ - { name: "name", header: "Name", type: "string", flex: 0.5, draggable: false, visible: true }, - { name: "desc", header: "Description", type: "string", flex: 1, draggable: false, visible: true }, - { name: "cidr", header: "Address Range", type: "string", flex: 0.30, draggable: false, visible: true }, - { name: "id", header: () => , width: 25, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => "", visible: true } + { field: "name", headerName: "Name", flex: 0.5 }, + { field: "desc", headerName: "Description", flex: 1 }, + { field: "cidr", headerName: "Address Range", flex: 0.30 }, ], []); - const filterValue = [ - { name: "name", operator: "contains", type: "string", value: "" }, - { name: "desc", operator: "contains", type: "string", value: "" }, - { name: "cidr", operator: "contains", type: "string", value: "" } - ]; - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } - - var body = [ - { "op": "add", "path": `/views/extsubnets`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body })); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; + // Extra menu items for Add/Edit/Delete/Manage actions + const extraMenuItems = React.useMemo(() => [ + { + icon: AddOutlined, + label: "Add Subnet", + onClick: () => setAddExtSubOpen(true), + disabled: !selectedExternal || !isAdmin, + }, + { + icon: EditOutlined, + label: "Edit Subnet", + onClick: () => setEditExtSubOpen(true), + disabled: !selectedSubnet || !isAdmin, + }, + { + icon: DeleteOutline, + label: "Remove Subnet", + onClick: () => setDelExtSubOpen(true), + disabled: !selectedSubnet || !isAdmin, + }, + { + icon: EditNoteOutlined, + label: "Manage Endpoints", + onClick: () => setManExtEndOpen(true), + disabled: !selectedSubnet, + }, + ], [selectedExternal, selectedSubnet, isAdmin]); + + // Handle row selection change + const handleRowSelectionChanged = React.useCallback((row) => { + setSelectedSubnet(row); + }, [setSelectedSubnet]); + + // Derive grid data synchronously from selectedExternal to prevent + // a flash of the no-rows overlay between selection and data display. + const gridData = React.useMemo(() => { + if (!selectedExternal) return []; + + const newSubnets = cloneDeep(selectedExternal['subnets']); + + return newSubnets.reduce((acc, curr) => { + curr['id'] = `${selectedExternal.name}@${curr.name}}`; + acc.push(curr); return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); - - const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState(null); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); - }, []); - - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); + }, []); + }, [selectedExternal]); + // Sync enriched subnets data to parent state (for dialogs, selection tracking, etc.) React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - subnets, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(subnets); - } - },[subnets, columnSortState]); + setSubnets(gridData); + }, [gridData, setSubnets]); + // Clear selection when subnets change React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); - - function onClick(data) { - var id = data.id; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - } - - return newSelectionModel; - }); - } - - React.useEffect(() => { - if(selectedExternal) { - var newSubnets = cloneDeep(selectedExternal['subnets']); - - const newData = newSubnets.reduce((acc, curr) => { - curr['id'] = `${selectedExternal.name}@${curr.name}}` - - acc.push(curr); - - return acc; - }, []); - - setSubnets(newData); - } else { - setSubnets(null) - } - }, [selectedExternal, setSubnets]); - - React.useEffect(() => { - if(Object.keys(selectionModel).length > 0) { - setSelectedSubnet(Object.values(selectionModel)[0]); - } else { + if (!subnets || subnets.length === 0) { setSelectedSubnet(null); } - }, [selectionModel, setSelectedSubnet]); + }, [subnets, setSelectedSubnet]); - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - - function NoRowsOverlay() { + // No rows overlay component + const NoRowsOverlay = React.useCallback(() => { return ( - - { selectedExternal - ? - No Subnets Found for Selected External Network - - : - Please Select an External Network - - } - + + + {selectedExternal + ? "No Subnets Found for Selected External Network" + : "Please Select an External Network" + } + + ); - } + }, [selectedExternal]); return ( - { isAdmin && + {isAdmin && { external={selectedExternal ? selectedExternal.name : null} subnet={selectedSubnet ? selectedSubnet : null} /> - - - - - External Subnets - - - - onClick(rowData.data)} - onCellDoubleClick={onCellDoubleClick} - selected={selectionModel} - emptyText={NoRowsOverlay} - style={gridStyle} - /> - + + + + External Subnets + + + + - + ); } diff --git a/ui/src/features/configure/externals/subnets/utils/addSubnet.jsx b/ui/src/features/configure/externals/subnets/utils/addSubnet.jsx index 88fd1421..562b2664 100644 --- a/ui/src/features/configure/externals/subnets/utils/addSubnet.jsx +++ b/ui/src/features/configure/externals/subnets/utils/addSubnet.jsx @@ -3,12 +3,12 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, Button, - Radio, + Divider, Tooltip, TextField, Autocomplete, @@ -16,10 +16,12 @@ import { DialogTitle, DialogActions, DialogContent, - Paper + ToggleButton, + ToggleButtonGroup, + Typography, } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { createBlockExtSubnetAsync @@ -37,27 +39,13 @@ import { cidrMasks } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function AddExtSubnet(props) { const { open, handleClose, space, block, external, subnets } = props; const { enqueueSnackbar } = useSnackbar(); - const [addBySize, setAddBySize] = React.useState(true); + const [mode, setMode] = React.useState("auto"); + const addBySize = mode === "auto"; const [maskOptions, setMaskOptions] = React.useState(null); const [maskInput, setMaskInput] = React.useState(''); @@ -71,6 +59,10 @@ export default function AddExtSubnet(props) { const dispatch = useDispatch(); + function onModeChange(_, newMode) { + if (newMode !== null) setMode(newMode); + } + function onCancel() { handleClose(); @@ -80,7 +72,7 @@ export default function AddExtSubnet(props) { setSelectedMask(maskOptions.length >= 1 ? maskOptions[1] : maskOptions[0]); - setAddBySize(true); + setMode("auto"); } function onSubmit() { @@ -175,8 +167,7 @@ export default function AddExtSubnet(props) { } const hasError = React.useMemo(() => { - var emptyCheck = false; - var errorCheck = false; + let emptyCheck, errorCheck; if (addBySize) { errorCheck = (subName.error || subDesc.error); @@ -205,100 +196,109 @@ export default function AddExtSubnet(props) { }, [external]); return ( -
- - - Add External Subnet - - - - - - Subnet name must be unique -
- Max of 64 characters -
- Can contain alphnumerics -
- Can contain underscore, hypen and period -
- Cannot start/end with underscore, hypen or period - - } + + + Add External Subnet + + + + + - Subnet name must be unique +
- Max of 64 characters +
- Can contain alphnumerics +
- Can contain underscore, hypen and period +
- Cannot start/end with underscore, hypen or period + + } + > + onNameChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "80%" }} + /> +
+ + - Max of 128 characters +
- Can contain alphnumerics +
- Can contain spaces +
- Can contain underscore, hypen, slash and period +
- Cannot start/end with underscore, hypen, slash or period + + } + > + onDescChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "80%" }} + /> +
+ + - onNameChange(event)} - inputProps={{ spellCheck: false }} - sx={{width: "80%" }} - /> -
- - - Max of 128 characters -
- Can contain alphnumerics -
- Can contain spaces -
- Can contain underscore, hypen, slash and period -
- Cannot start/end with underscore, hypen, slash or period - - } + Allocation Mode + + - onDescChange(event)} - inputProps={{ spellCheck: false }} - sx={{ width: "80%" }} - /> -
- - - setAddBySize(true)} - name="add-by-size" - sx={{ pl: 0 }} - /> - - + Auto + Manual + + + + + {mode === "auto" ? ( + option.name} inputValue={maskInput} - onInputChange={(event, newInputValue) => setMaskInput(newInputValue)} + onInputChange={(_, newInputValue) => setMaskInput(newInputValue)} value={selectedMask} - onChange={(event, newValue) => setSelectedMask(newValue)} - sx={{ width: '6ch' }} + onChange={(_, newValue) => setSelectedMask(newValue)} + sx={{ width: '7ch' }} ListboxProps={{ - style: { - maxHeight: "15rem" - }, + style: { maxHeight: "15rem" }, position: "bottom-start" }} renderInput={(params) => ( @@ -311,15 +311,8 @@ export default function AddExtSubnet(props) { )} /> - - setAddBySize(false)} - name="add-by-cidr" - sx={{ pl: 0 }} - /> - - + ) : ( + - Must be in valid CIDR notation format -
    - Example: 1.2.3.4/5 +
- Example: 1.2.3.4/5
- Cannot overlap existing subnets } > 0 && subCidr.error) } + autoFocus + error={subCidr.value.length > 0 && subCidr.error} margin="dense" - id="name" + id="cidr" label="CIDR" - type="cidr" + placeholder="x.x.x.x/x" variant="standard" value={subCidr.value} onChange={(event) => onCidrChange(event)} inputProps={{ spellCheck: false }} - sx={{ - width: "100%", - pointerEvents: addBySize ? 'none' : 'auto' - }} + sx={{ width: "20ch" }} />
-
+ )}
-
- - - - Add - - -
-
+ + + + + + + ); } diff --git a/ui/src/features/configure/externals/subnets/utils/deleteSubnet.jsx b/ui/src/features/configure/externals/subnets/utils/deleteSubnet.jsx index 2ee07012..64e8eff0 100644 --- a/ui/src/features/configure/externals/subnets/utils/deleteSubnet.jsx +++ b/ui/src/features/configure/externals/subnets/utils/deleteSubnet.jsx @@ -4,7 +4,7 @@ import { styled } from "@mui/material/styles"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -17,10 +17,9 @@ import { DialogActions, DialogContent, DialogContentText, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { deleteBlockExtSubnetAsync } from "../../../../ipam/ipamSlice"; @@ -29,21 +28,6 @@ const Spotlight = styled("span")(({ theme }) => ({ color: theme.palette.mode === 'dark' ? 'cornflowerblue' : 'mediumblue' })); -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function DeleteExtSubnet(props) { const { open, handleClose, space, block, external, subnet } = props; @@ -102,7 +86,7 @@ export default function DeleteExtSubnet(props) { - Please confirm you want to delete External Subnet '{subnet}' + Please confirm you want to delete External Subnet {`'${subnet}'`} @@ -122,13 +106,13 @@ export default function DeleteExtSubnet(props) { - Delete - + diff --git a/ui/src/features/configure/externals/subnets/utils/editSubnet.jsx b/ui/src/features/configure/externals/subnets/utils/editSubnet.jsx index 952b3992..ca5957ba 100644 --- a/ui/src/features/configure/externals/subnets/utils/editSubnet.jsx +++ b/ui/src/features/configure/externals/subnets/utils/editSubnet.jsx @@ -3,7 +3,7 @@ import { useDispatch } from "react-redux"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { Box, @@ -14,10 +14,9 @@ import { DialogTitle, DialogActions, DialogContent, - Paper } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { updateBlockExtSubnetAsync @@ -34,21 +33,6 @@ import { CIDR_REGEX } from "../../../../../global/globals"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function EditExtSubnet(props) { const { open, handleClose, space, block, external, subnets, selectedSubnet } = props; @@ -195,11 +179,8 @@ export default function EditExtSubnet(props) { }, [selectedSubnet, subName, subDesc, subCidr]); const hasError = React.useMemo(() => { - var emptyCheck = false; - var errorCheck = false; - - errorCheck = (subName.error || subDesc.error || subCidr.error); - emptyCheck = (subName.value.length === 0 || subDesc.value.length === 0 || subCidr.value.length === 0); + const errorCheck = (subName.error || subDesc.error || subCidr.error); + const emptyCheck = (subName.value.length === 0 || subDesc.value.length === 0 || subCidr.value.length === 0); return (errorCheck || emptyCheck); }, [subName, subDesc, subCidr]); @@ -321,13 +302,13 @@ export default function EditExtSubnet(props) { > Cancel - Update - + diff --git a/ui/src/features/configure/externals/subnets/utils/manageEndpoints.jsx b/ui/src/features/configure/externals/subnets/utils/manageEndpoints.jsx index 9e763ba3..6a024372 100644 --- a/ui/src/features/configure/externals/subnets/utils/manageEndpoints.jsx +++ b/ui/src/features/configure/externals/subnets/utils/manageEndpoints.jsx @@ -2,15 +2,15 @@ import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; import { styled } from "@mui/material/styles"; -import { omit, isEmpty, isEqual, pickBy, orderBy, cloneDeep } from "lodash"; +import { omit, isEqual, cloneDeep } from "lodash"; import { useSnackbar } from "notistack"; -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; +import { AgGridReact } from "ag-grid-react"; +import { themeQuartz } from "ag-grid-community"; +import { SpinnerDotted } from 'spinners-react'; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../../global/DraggablePaper"; import { useTheme } from "@mui/material/styles"; @@ -31,9 +31,9 @@ import { ListItemIcon, OutlinedInput, Tooltip, - Paper, Autocomplete, - TextField + TextField, + Typography } from "@mui/material"; import { @@ -47,11 +47,10 @@ import { PlaylistAddOutlined, PlaylistAddCheckOutlined, PlaylistRemoveOutlined, - // HighlightOff, InfoOutlined } from "@mui/icons-material"; -import LoadingButton from "@mui/lab/LoadingButton"; + import { replaceBlockExtSubnetEndpointsAsync, @@ -85,21 +84,89 @@ const Update = styled("span")(({ theme }) => ({ textShadow: '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white' })); -const gridStyle = { - height: '100%', - border: '1px solid rgba(224, 224, 224, 1)', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; +// ============================================================================ +// Combined Overlay Component (AG Grid v35+) +// ============================================================================ -function RenderDelete(props) { - const { value } = props; - const { setChanges, selectionModel } = React.useContext(EndpointContext); +// Context for passing reactive overlay config to CombinedOverlay. +const OverlayContext = React.createContext(null); + +const CombinedOverlay = React.memo(({ overlayType }) => { + const overlayConfig = React.useContext(OverlayContext); + const loadingMessage = overlayConfig?.loadingMessage; + const NoRowsContent = overlayConfig?.noRowsOverlay; + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + if (overlayType === 'loading') { + return ( + + + + {loadingMessage || 'Loading data...'} + + + ); + } + + // noRows / noMatchingRows + if (NoRowsContent) { + return ; + } + + return ( + + + No endpoints found + + + ); +}); + +CombinedOverlay.displayName = 'CombinedOverlay'; + +function DeleteCellRenderer(props) { + const { data } = props; + const { setChanges, selectedRow } = React.useContext(EndpointContext); const flexCenter = { display: "flex", alignItems: "center", - justifyContent: "center" - } + justifyContent: "center", + height: "100%" + }; + + const isSelected = selectedRow && selectedRow.id === data.id; return ( @@ -108,23 +175,17 @@ function RenderDelete(props) { color="error" sx={{ padding: 0, - display: (isEqual([value.id], Object.keys(selectionModel))) ? "flex" : "none" + display: isSelected ? "flex" : "none" }} disableFocusRipple disableTouchRipple disableRipple onClick={() => { - var endpointDetails = cloneDeep(value); - + var endpointDetails = cloneDeep(data); endpointDetails['op'] = "delete"; - - setChanges(prev => [ - ...prev, - endpointDetails - ]); + setChanges(prev => [...prev, endpointDetails]); }} > - {/* */} @@ -144,22 +205,22 @@ function HeaderMenu(props) { const onClick = () => { setMenuOpen(prev => !prev); - } + }; const onSave = () => { saveConfig(); setMenuOpen(false); - } + }; const onLoad = () => { loadConfig(); setMenuOpen(false); - } + }; const onReset = () => { resetConfig(); setMenuOpen(false); - } + }; return ( { @@ -236,7 +299,7 @@ function HeaderMenu(props) { > @@ -259,21 +322,6 @@ function HeaderMenu(props) {
} - ) -} - -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - ); } @@ -295,13 +343,9 @@ export default function ManageExtEndpoints(props) { const [endpoints, setEndpoints] = React.useState(null); const [addressOptions, setAddressOptions] = React.useState([]); const [changes, setChanges] = React.useState([]); - const [gridData, setGridData] = React.useState(null); - const [sending, setSending] = React.useState(false); - const [selectionModel, setSelectionModel] = React.useState({}); - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); + const [sending, setSending] = React.useState(false); + const [selectedRow, setSelectedRow] = React.useState(null); const [endName, setEndName] = React.useState({ value: "", error: true }); const [endDesc, setEndDesc] = React.useState({ value: "", error: true }); @@ -313,114 +357,123 @@ export default function ManageExtEndpoints(props) { const viewSetting = useSelector(state => selectViewSetting(state, 'extendpoints')); const dispatch = useDispatch(); + const gridRef = React.useRef(null); - const saveTimer = React.useRef(); + const saveTimer = React.useRef(null); const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; const unchanged = (subnet && endpoints) ? isEqual(subnet['endpoints'], endpoints.map(({id, ...rest}) => rest)) : false; + // AG Grid column definitions const columns = React.useMemo(() => [ - { name: "name", header: "Name", type: "string", flex: 0.5, draggable: false, visible: true }, - { name: "desc", header: "Description", type: "string", flex: 1, draggable: false, visible: true }, - { name: "ip", header: "IP Address", type: "string", flex: 0.30, draggable: false, visible: true }, - { name: "id", header: () => , width: 25, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => , visible: true } + { field: "name", headerName: "Name", flex: 0.5 }, + { field: "desc", headerName: "Description", flex: 1 }, + { field: "ip", headerName: "IP Address", flex: 0.30 }, + { + field: "actions", + headerName: "", + headerComponent: () => , + width: 50, + resizable: false, + sortable: false, + suppressColumnsToolPanel: true, + cellRenderer: DeleteCellRenderer + } ], []); - const filterValue = [ - { name: "name", operator: "contains", type: "string", value: "" }, - { name: "desc", operator: "contains", type: "string", value: "" }, - { name: "ip", operator: "contains", type: "string", value: "" } - ]; - - function onClick(data) { - var id = data.id; - var newSelectionModel = {}; - - setSelectionModel(prevState => { - if(!prevState.hasOwnProperty(id)) { - newSelectionModel[id] = data; - - setEndName({ value: data.name, error: false }); - setEndDesc({ value: data.desc, error: false }); - setEndAddrInput(data.ip); - setEndAddr(data.ip); - - const endpointAddresses = endpoints.map(e => { - if (data.ip !== e.ip) { - return e.ip; - } - }); - - const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr)); - - setAddressOptions(newAddressOptions); - } else { - setEndName({ value: "", error: true }); - setEndDesc({ value: "", error: true }); - setEndAddrInput(""); - setEndAddr(null); - - const endpointAddresses = endpoints.map(e => e.ip); - const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr)); - - setAddressOptions(["", ...newAddressOptions]); - } - - return newSelectionModel; - }); - } - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - console.log(batchColumnInfo); - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; - } - }); + // Grid theme with compactness + const gridTheme = React.useMemo(() => { + const baseParams = { + spacing: 7, // default is 8, reduced for tighter layout + }; - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } + return themeQuartz + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(255, 255, 255, 0.66)' }, 'light') + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(0, 0, 0, 0.2)' }, 'dark'); + }, []); - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; + // Overlay context value for CombinedOverlay + const overlayContextValue = React.useMemo(() => ({ + loadingMessage: sending ? 'Updating...' : 'Loading data...', + }), [sending]); + + // Default column definitions + const defaultColDef = React.useMemo(() => ({ + resizable: true, + sortable: true, + filter: true, + suppressHeaderMenuButton: true, + }), []); + + // Handle row selection + const onRowClicked = React.useCallback((event) => { + if (!isAdmin) return; + + const data = event.data; + const node = event.node; + + if (selectedRow && selectedRow.id === data.id) { + // Deselect + node.setSelected(false); + setSelectedRow(null); + setEndName({ value: "", error: true }); + setEndDesc({ value: "", error: true }); + setEndAddrInput(""); + setEndAddr(null); - acc[name] = { flex, visible }; + const endpointAddresses = endpoints.map(e => e.ip); + const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr)); + setAddressOptions(["", ...newAddressOptions]); + } else { + // Select + node.setSelected(true); + setSelectedRow(data); + setEndName({ value: data.name, error: false }); + setEndDesc({ value: data.desc, error: false }); + setEndAddrInput(data.ip); + setEndAddr(data.ip); + + const endpointAddresses = endpoints.map(e => { + if (data.ip !== e.ip) { + return e.ip; + } + }); + const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr)); + setAddressOptions(newAddressOptions); + } + }, [isAdmin, selectedRow, endpoints, subnet]); + + // Handle cell double click for copy + const onCellDoubleClicked = React.useCallback((event) => { + const value = event.value; + if (value !== undefined && value !== null) { + navigator.clipboard.writeText(value); + enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); + } + }, [enqueueSnackbar]); - return acc; - }, {}); + const saveConfig = React.useCallback(() => { + const api = gridRef.current?.api; + if (!api) return; + + const columnState = api.getColumnState(); + // Filter out system columns and non-essential properties + const cleanedState = columnState + .filter(col => !col.colId.startsWith('ag-') && col.colId !== 'actions') + .map(({ colId, width, flex, sort, sortIndex, hide }) => ({ + colId, + width, + flex, + sort, + sortIndex, + hide + })); const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState - } + columnState: cleanedState + }; var body = [ { "op": "add", "path": `/views/extendpoints`, "value": saveData } @@ -442,65 +495,31 @@ export default function ManageExtEndpoints(props) { setSaving(false); } })(); - }; + }, [dispatch, enqueueSnackbar]); const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; - - return acc; - }, {}) + const api = gridRef.current?.api; + if (!api || !viewSetting) return; - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); + // Handle both old format (values, order, sort) and new format (columnState) + if (viewSetting.columnState) { + api.applyColumnState({ state: viewSetting.columnState, applyOrder: true }); + } + }, [viewSetting]); const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState(null); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); + const api = gridRef.current?.api; + if (!api) return; + api.resetColumnState(); }, []); - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } - } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); - - React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - endpoints, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(endpoints); + // Handle grid ready + const onGridReady = React.useCallback(() => { + // Auto-load saved view if available + if (viewSetting?.columnState) { + loadConfig(); } - },[endpoints, columnSortState]); + }, [viewSetting, loadConfig]); React.useEffect(() => { if(sendResults !== null) { @@ -514,6 +533,8 @@ export default function ManageExtEndpoints(props) { } }, [saveTimer, sendResults]); + + function onAddExternal() { if(!hasError) { var endpointDetails = { @@ -524,12 +545,12 @@ export default function ManageExtEndpoints(props) { endpointDetails['id'] = md5(JSON.stringify(endpointDetails)); - if (Object.keys(selectionModel).length !== 0) { + if (selectedRow) { const updates = { op: "update", - old: Object.values(selectionModel)[0], + old: selectedRow, new: endpointDetails - } + }; setChanges(prev => [ ...prev, @@ -598,7 +619,7 @@ export default function ManageExtEndpoints(props) { if (open) { handleClose(); - setSelectionModel({}); + setSelectedRow(null); setChanges([]); setEndName({ value: "", error: true }); @@ -608,13 +629,6 @@ export default function ManageExtEndpoints(props) { } }, [open, handleClose]); - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - function onNameChange(event) { const newName = event.target.value; @@ -625,8 +639,8 @@ export default function ManageExtEndpoints(props) { const nameError = newName ? !regex.test(newName) : false; const nameExists = endpoints?.reduce((acc, curr) => { - if(Object.keys(selectionModel).length !== 0) { - if (curr['name'].toLowerCase() !== Object.values(selectionModel)[0].name.toLowerCase()) { + if(selectedRow) { + if (curr['name'].toLowerCase() !== selectedRow.name.toLowerCase()) { acc.push(curr['name'].toLowerCase()); } } else { @@ -713,7 +727,7 @@ export default function ManageExtEndpoints(props) { }, [subnet, changes, onCancel]); return ( - + - Define the Endpoints below which should be associated with the Subnet '{subnet && subnet.name}' + Define the Endpoints below which should be associated with the Subnet {`'${subnet && subnet.name}'`} { isAdmin && @@ -763,7 +777,7 @@ export default function ManageExtEndpoints(props) { borderStyle: 'solid', borderColor: 'rgb(224, 224, 224)', backgroundColor: theme.palette.mode === 'dark' ? 'rgb(80, 80, 80)' : 'rgb(240, 240, 240)' - + }} > { - Object.keys(selectionModel).length !== 0 ? + selectedRow ? "Edit Existing Endpoint" : "Add New Endpoint" } @@ -798,7 +812,7 @@ export default function ManageExtEndpoints(props) { display: 'flex', flex: '1 1 auto', alignItems: 'center', - width: columnState && columnState[0].flex > 1 ? columnState[0].flex : 'calc(((100% - 40px) / 1.80) * 0.5)', + width: 'calc(((100% - 50px) / 1.80) * 0.5)', borderRight: '1px solid rgb(224, 224, 224)' }} style={ @@ -860,7 +874,7 @@ export default function ManageExtEndpoints(props) { display: 'flex', flex: '1 1 auto', alignItems: 'center', - width: columnState && columnState[1].flex > 1 ? columnState[1].flex : 'calc(((100% - 40px) / 1.80) * 1)', + width: 'calc(((100% - 50px) / 1.80) * 1)', borderRight: '1px solid rgb(224, 224, 224)' }} style={ @@ -922,7 +936,7 @@ export default function ManageExtEndpoints(props) { display: 'flex', flex: '1 1 auto', alignItems: 'center', - width: columnState && columnState[2].flex > 1 ? columnState[2].flex : 'calc(((100% - 40px) / 1.80) * 0.3)', + width: 'calc(((100% - 50px) / 1.80) * 0.3)', }} style={ theme.palette.mode === 'dark' @@ -943,6 +957,7 @@ export default function ManageExtEndpoints(props) { value={endAddr} onChange={(event, newValue) => setEndAddr(newValue)} isOptionEqualToValue={(option, value) => isEqual(option, value)} + ListboxProps={{ style: { maxHeight: 375 } }} sx={{ width: 300 }} renderInput={(params) => ( { - Object.keys(selectionModel).length !== 0 ? + selectedRow ? - Updating : "Loading"} - dataSource={gridData || []} - sortInfo={columnSortState} - defaultFilterValue={filterValue} - onRowClick={(rowData) => { isAdmin && onClick(rowData.data)}} - onCellDoubleClick={onCellDoubleClick} - selected={selectionModel} - style={gridStyle} - /> + + + params.data.id} + accentedSort={true} + rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: false }} + cellSelection={false} + suppressCellFocus={true} + onRowClicked={onRowClicked} + onCellDoubleClicked={onCellDoubleClicked} + onGridReady={onGridReady} + loading={sending || !endpoints || refreshing} + overlayComponent={CombinedOverlay} + /> + + @@ -1089,14 +1099,13 @@ export default function ManageExtEndpoints(props) { > Cancel - Apply - + diff --git a/ui/src/features/configure/reservations/reservations.jsx b/ui/src/features/configure/reservations/reservations.jsx index d9021ec6..85e0b6d6 100644 --- a/ui/src/features/configure/reservations/reservations.jsx +++ b/ui/src/features/configure/reservations/reservations.jsx @@ -1,27 +1,18 @@ import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; import { useLocation } from "react-router"; -import { styled } from "@mui/material/styles"; -import { useTheme } from '@mui/material/styles'; -import { isEmpty, isEqual, pickBy, orderBy, sortBy, cloneDeep, pick } from "lodash"; +import { isEmpty, isEqual, sortBy, pick } from "lodash"; import { useSnackbar } from "notistack"; import moment from "moment"; -import ReactDataGrid from "@inovua/reactdatagrid-community"; -import "@inovua/reactdatagrid-community/index.css"; -import "@inovua/reactdatagrid-community/theme/default-dark.css"; -import DateFilter from "@inovua/reactdatagrid-community/DateFilter"; +import { DataGrid } from "../../../global/grids/DataGrid"; import { Box, IconButton, - Menu, - MenuItem, - ListItemIcon, - Divider, TextField, Autocomplete, Typography, @@ -38,12 +29,6 @@ import { ErrorOutline, BlockOutlined, TimerOffOutlined, - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined, VisibilityOutlined, VisibilityOffOutlined, PieChartOutlined, @@ -55,9 +40,7 @@ import { selectSpaces, selectBlocks, fetchSpacesAsync, - deleteBlockResvsAsync, - selectViewSetting, - updateMeAsync + deleteBlockResvsAsync } from "../../ipam/ipamSlice"; import NewReservation from "./utils/newReservation"; @@ -93,8 +76,13 @@ const MESSAGE_MAP = { icon: WarningAmber, color: "warning" }, + "errCIDROverlap": { + msg: "A vNET with overlapping CIDR has already been associated with the target IP Block.", + icon: ErrorOutline, + color: "error" + }, "errCIDRExists": { - msg: "A vNET with the assigned CIDR has already been associated with the target IP Block.", + msg: "A vNET with overlapping CIDR has already been associated with the target IP Block.", icon: ErrorOutline, color: "error" }, @@ -112,192 +100,6 @@ const MESSAGE_MAP = { const ReservationContext = React.createContext({}); -const Update = styled("span")(({ theme }) => ({ - fontWeight: 'bold', - color: theme.palette.error.light, - textShadow: '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white' -})); - -const gridStyle = { - height: '100%', - border: '1px solid rgba(224, 224, 224, 1)', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif' -}; - -function HeaderMenu(props) { - const { setting } = props; - const { - filterActive, - setFilterActive, - selectedSpace, - selectedBlock, - setNewResvOpen, - saving, - sendResults, - saveConfig, - loadConfig, - resetConfig - } = React.useContext(ReservationContext); - - const [menuOpen, setMenuOpen] = React.useState(false); - - const menuRef = React.useRef(null); - - const viewSetting = useSelector(state => selectViewSetting(state, setting)); - - const onClick = () => { - setMenuOpen(prev => !prev); - } - - const onActive = () => { - setFilterActive(prev => !prev); - setMenuOpen(false); - } - - const onNewResv = () => { - setNewResvOpen(true); - setMenuOpen(false); - } - - const onSave = () => { - saveConfig(); - setMenuOpen(false); - } - - const onLoad = () => { - loadConfig(); - setMenuOpen(false); - } - - const onReset = () => { - resetConfig(); - setMenuOpen(false); - } - - return ( - - { - saving ? - - - : - (sendResults !== null) ? - - { - sendResults ? - : - - } - : - - - - - - - - { - filterActive ? - : - - } - - { filterActive ? 'Showing Active' : 'Showing All' } - - - - - - - New Reservation - - - - - - - Load Saved View - - - - - - Save Current View - - - - - - Reset Default View - - - - } - - ) -} - function ReservationStatus(props) { const { value } = props; @@ -415,255 +217,101 @@ const Reservations = () => { const [refreshing, setRefreshing] = React.useState(false); const [filterActive, setFilterActive] = React.useState(true); - const [saving, setSaving] = React.useState(false); - const [sendResults, setSendResults] = React.useState(null); const [reservations, setReservations] = React.useState([]); - const [gridData, setGridData] = React.useState(null); - const [selectionModel, setSelectionModel] = React.useState({}); + const [selectedRows, setSelectedRows] = React.useState([]); const [copied, setCopied] = React.useState(""); const [sending, setSending] = React.useState(false); const [newResvOpen, setNewResvOpen] = React.useState(location.state?.cidr ? true : false); - const [columnState, setColumnState] = React.useState(null); - const [columnOrderState, setColumnOrderState] = React.useState([]); - const [columnSortState, setColumnSortState] = React.useState({}); - const spaces = useSelector(selectSpaces); const blocks = useSelector(selectBlocks); - const viewSetting = useSelector(state => selectViewSetting(state, 'reservations')); - const msgTimer = React.useRef(); - const saveTimer = React.useRef(); + const msgTimer = React.useRef(null); const dispatch = useDispatch(); - const theme = useTheme(); - - window.moment = moment; - - const filterTypes = Object.assign({}, ReactDataGrid.defaultProps.filterTypes, { - unixdate: { - name: 'unixdate', - emptyValue: '', - operators: [ - { - name: 'after', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? moment.unix(value).isAfter(window.moment(filterValue, column.dateFormat)) : true; - } - }, - { - name: 'afterOrOn', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? moment.unix(value).isSameOrAfter(window.moment(filterValue, column.dateFormat)) : true; - } - }, - { - name: 'before', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? moment.unix(value).isBefore(window.moment(filterValue, column.dateFormat)) : true; - } - }, - { - name: 'beforeOrOn', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? moment.unix(value).isSameOrBefore(window.moment(filterValue, column.dateFormat)) : true; - } - }, - { - name: 'eq', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? moment.unix(value).isSame(window.moment(filterValue, column.dateFormat)) : true; - } - }, - { - name: 'neq', - fn: ({ value, filterValue, column, data }) => { - return filterValue !== (null || '') ? !moment.unix(value).isSame(window.moment(filterValue, column.dateFormat)) : true; - } - } - ] - } - }); const columns = React.useMemo(() => [ - { name: "cidr", header: "CIDR", type: "string", flex: 0.5, visible: true }, - { name: "createdBy", header: "Created By", type: "string", flex: 1, visible: true }, - { name: "desc", header: "Description", type: "string", flex: 1.5, visible: true }, + { field: "cidr", headerName: "CIDR", flex: 0.5 }, + { field: "createdBy", headerName: "Created By", flex: 1 }, + { field: "desc", headerName: "Description", flex: 1.5 }, { - name: "createdOn", - header: "Creation Date", - type: "unixdate", + field: "createdOn", + headerName: "Creation Date", flex: 0.75, - dateFormat: 'lll', - filterEditor: DateFilter, - filterEditorProps: (props, { index }) => { - return { - dateFormat: 'lll', + valueFormatter: (params) => params.value ? moment.unix(params.value).format('lll') : null, + filter: 'agDateColumnFilter', + filterParams: { + comparator: (filterDate, cellValue) => { + if (!cellValue) return -1; + const cellDate = moment.unix(cellValue).startOf('day').toDate(); + const filterDateStart = moment(filterDate).startOf('day').toDate(); + if (cellDate < filterDateStart) return -1; + if (cellDate > filterDateStart) return 1; + return 0; } - }, - render: ({value}) => moment.unix(value).format('lll'), - visible: true + } }, { - name: "settledOn", - header: "Settled Date", - type: "unixdate", + field: "settledOn", + headerName: "Settled Date", flex: 0.75, - dateFormat: 'lll', - filterEditor: DateFilter, - filterEditorProps: (props, { index }) => { - return { - dateFormat: 'lll', + hide: true, + valueFormatter: (params) => params.value ? moment.unix(params.value).format('lll') : null, + filter: 'agDateColumnFilter', + filterParams: { + comparator: (filterDate, cellValue) => { + if (!cellValue) return -1; + const cellDate = moment.unix(cellValue).startOf('day').toDate(); + const filterDateStart = moment(filterDate).startOf('day').toDate(); + if (cellDate < filterDateStart) return -1; + if (cellDate > filterDateStart) return 1; + return 0; } - }, - render: ({value}) => value ? moment.unix(value).format('lll') : null, - visible: false - }, - { name: "settledBy", header: "Settled By", type: "string", flex: 1, visible: false }, - { name: "status", header: "Status", headerAlign: "center", width: 90, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({value}) => , visible: true }, - { name: "id", header: () => , width: 25, resizable: false, hideable: false, sortable: false, draggable: false, showColumnMenuTool: false, render: ({data}) => , visible: true } - ], []); - - const filterValue = [ - { name: "cidr", operator: "contains", type: "string", value: "" }, - { name: "createdBy", operator: "contains", type: "string", value: "" }, - { name: "desc", operator: "contains", type: "string", value: "" }, - { name: "createdOn", operator: "afterOrOn", type: "unixdate", value: "" }, - { name: "settledOn", operator: "afterOrOn", type: "unixdate", value: "" }, - { name: "settledBy", operator: "contains", type: "string", value: "" } - ]; - - const onBatchColumnResize = (batchColumnInfo) => { - const colsMap = batchColumnInfo.reduce((acc, colInfo) => { - const { column, flex } = colInfo - acc[column.name] = { flex } - return acc - }, {}); - - const newColumns = columnState.map(c => { - return Object.assign({}, c, colsMap[c.name]); - }) - - setColumnState(newColumns); - } - - const onColumnOrderChange = (columnOrder) => { - setColumnOrderState(columnOrder); - } - - const onColumnVisibleChange = ({ column, visible }) => { - const newColumns = columnState.map(c => { - if(c.name === column.name) { - return Object.assign({}, c, { visible }); - } else { - return c; } - }); - - setColumnState(newColumns); - } - - const onSortInfoChange = (sortInfo) => { - setColumnSortState(sortInfo); - } - - const saveConfig = () => { - const values = columnState.reduce((acc, colInfo) => { - const { name, flex, visible } = colInfo; - - acc[name] = { flex, visible }; - - return acc; - }, {}); - - const saveData = { - values: values, - order: columnOrderState, - sort: columnSortState + }, + { field: "settledBy", headerName: "Settled By", flex: 1, hide: true }, + { + field: "status", + headerName: "Status", + width: 90, + minWidth: 90, + maxWidth: 90, + resizable: false, + sortable: false, + filter: false, + cellStyle: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, + cellRenderer: (params) => } + ], []); - var body = [ - { "op": "add", "path": `/views/reservations`, "value": saveData } - ]; - - (async () => { - try { - setSaving(true); - await dispatch(updateMeAsync({ body: body })); - setSendResults(true); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - setSendResults(false); - enqueueSnackbar("Error saving view settings", { variant: "error" }); - } finally { - setSaving(false); - } - })(); - }; - - const loadConfig = React.useCallback(() => { - const { values, order, sort } = viewSetting; - - const colsMap = columns.reduce((acc, colInfo) => { - - acc[colInfo.name] = colInfo; - - return acc; - }, {}) - - const loadColumns = order.map(item => { - const assigned = pickBy(values[item], v => v !== undefined) - - return Object.assign({}, colsMap[item], assigned); - }); - - setColumnState(loadColumns); - setColumnOrderState(order); - setColumnSortState(sort); - }, [columns, viewSetting]); - - const resetConfig = React.useCallback(() => { - setColumnState(columns); - setColumnOrderState(columns.flatMap(({name}) => name)); - setColumnSortState({ name: 'createdOn', dir: 1, type: 'date' }); - }, [columns]); - - const renderColumnContextMenu = React.useCallback((menuProps) => { - const columnIndex = menuProps.items.findIndex((item) => item.itemId === 'columns'); - const idIndex = menuProps.items[columnIndex].items.findIndex((item) => item.value === 'id'); - - menuProps.items[columnIndex].items.splice(idIndex, 1); + const actionsCellRenderer = React.useCallback((params) => { + return ; }, []); - React.useEffect(() => { - if(!columnState && viewSetting) { - if(columns && !isEmpty(viewSetting)) { - loadConfig(); - } else { - resetConfig(); - } + const extraMenuItems = React.useMemo(() => [ + { + icon: filterActive ? VisibilityOffOutlined : VisibilityOutlined, + label: filterActive ? 'Showing Active' : 'Showing All', + onClick: () => setFilterActive(prev => !prev) + }, + { + icon: PieChartOutlined, + label: 'New Reservation', + onClick: () => setNewResvOpen(true), + disabled: !(selectedSpace && selectedBlock) } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); + ], [filterActive, selectedSpace, selectedBlock]); - React.useEffect(() => { - const newReservations = filterActive ? reservations.filter(x => x.settledOn === null) : reservations; + const onRowSelectionChanged = React.useCallback((rows) => { + setSelectedRows(rows); + }, []); - if(columnSortState) { - setGridData( - orderBy( - newReservations, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(newReservations); - } - }, [reservations, filterActive, columnSortState]); + // Derive filtered grid data synchronously to prevent a flash of + // the no-rows overlay when reservations or filter state changes. + const gridData = React.useMemo(() => { + return filterActive ? reservations.filter(x => x.settledOn === null) : reservations; + }, [reservations, filterActive]); React.useEffect(() => { if(copied !== "") { @@ -677,18 +325,6 @@ const Reservations = () => { } }, [msgTimer, copied]); - React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); - React.useEffect(() => { if (spaces) { if (selectedSpace) { @@ -744,23 +380,11 @@ const Reservations = () => { React.useEffect(() => { if (!isEmpty(reservations)) { - setSelectionModel((prev) => { - const newSelectionmodel = cloneDeep(prev); - - Object.keys(prev).forEach((key) => { - const found = reservations.find((x) => x.id === key); - - if (!found) { - delete newSelectionmodel[key]; - } - }); - - return newSelectionmodel; + setSelectedRows((prev) => { + return prev.filter(row => reservations.find(x => x.id === row.id)); }); - - // setSelectionModel(newSelectionmodel); } else { - setSelectionModel([]); + setSelectedRows([]); } }, [reservations]); @@ -785,9 +409,8 @@ const Reservations = () => { (async () => { try { setSending(true); - await dispatch(deleteBlockResvsAsync({ space: selectedBlock.parent_space, block: selectedBlock.name, body: Object.keys(selectionModel) })); - setSelectionModel([]); - setFilterActive(true); + await dispatch(deleteBlockResvsAsync({ space: selectedBlock.parent_space, block: selectedBlock.name, body: selectedRows.map(r => r.id) })); + setSelectedRows([]); enqueueSnackbar("Successfully removed IP Block reservation(s)", { variant: "success" }); } catch (e) { console.log("ERROR"); @@ -801,14 +424,7 @@ const Reservations = () => { })(); } - const onCellDoubleClick = React.useCallback((event, cellProps) => { - const { value } = cellProps - - navigator.clipboard.writeText(value); - enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); - }, [enqueueSnackbar]); - - function NoRowsOverlay() { + const NoRowsOverlay = React.useCallback(() => { return ( { selectedBlock @@ -825,10 +441,10 @@ const Reservations = () => { } ); - } + }, [selectedBlock, filterActive]); return ( - + setNewResvOpen(false)} @@ -950,7 +566,7 @@ const Reservations = () => { title="Remove" placement="top" style={{ - visibility: (isEmpty(selectionModel) || refreshing) ? 'hidden' : 'visible' + visibility: (isEmpty(selectedRows) || refreshing) ? 'hidden' : 'visible' }} > @@ -983,38 +599,17 @@ const Reservations = () => { - Updating : "Loading"} - dataSource={gridData || []} - selected={selectionModel} - onSelectionChange={({selected}) => setSelectionModel(selected)} - onCellDoubleClick={onCellDoubleClick} - sortInfo={columnSortState} - filterTypes={filterTypes} - defaultFilterValue={filterValue} - emptyText={NoRowsOverlay} - style={gridStyle} + checkboxSelect={true} + extraMenuItems={extraMenuItems} + isLoading={sending || refreshing} + noRowsOverlay={NoRowsOverlay} + actionsCellRenderer={actionsCellRenderer} /> @@ -1023,4 +618,4 @@ const Reservations = () => { ); } -export default Reservations; +export default Reservations; \ No newline at end of file diff --git a/ui/src/features/configure/reservations/utils/newReservation.jsx b/ui/src/features/configure/reservations/utils/newReservation.jsx index d15adf76..9818f6a4 100644 --- a/ui/src/features/configure/reservations/utils/newReservation.jsx +++ b/ui/src/features/configure/reservations/utils/newReservation.jsx @@ -4,11 +4,12 @@ import { useLocation } from "react-router"; import { useSnackbar } from "notistack"; -import Draggable from "react-draggable"; +import DraggablePaper from "../../../../global/DraggablePaper"; import { Box, Button, + Divider, Tooltip, TextField, Dialog, @@ -18,68 +19,24 @@ import { FormGroup, FormControlLabel, Autocomplete, - Radio, Switch, - Paper + ToggleButton, + ToggleButtonGroup, + Typography, } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { createBlockResvAsync } from "../../../ipam/ipamSlice"; import { SPACE_DESC_REGEX, - CIDR_REGEX + CIDR_REGEX, + cidrMasks } from "../../../../global/globals"; -const cidrMasks = [ - { name: '/8', value: 8}, - { name: '/9', value: 9}, - { name: '/10', value: 10}, - { name: '/11', value: 11}, - { name: '/12', value: 12}, - { name: '/13', value: 13}, - { name: '/14', value: 14}, - { name: '/15', value: 15}, - { name: '/16', value: 16}, - { name: '/17', value: 17}, - { name: '/18', value: 18}, - { name: '/19', value: 19}, - { name: '/20', value: 20}, - { name: '/21', value: 21}, - { name: '/22', value: 22}, - { name: '/23', value: 23}, - { name: '/24', value: 24}, - { name: '/25', value: 25}, - { name: '/26', value: 26}, - { name: '/27', value: 27}, - { name: '/28', value: 28}, - { name: '/29', value: 29}, - { name: '/30', value: 30}, - { name: '/31', value: 31}, - { name: '/32', value: 32} -]; - -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function NewReservation(props) { const { open, handleClose, selectedSpace, selectedBlock } = props; - const spaces = []; const { enqueueSnackbar } = useSnackbar(); @@ -98,19 +55,27 @@ export default function NewReservation(props) { const [invalidForm, setInvalidForm] = React.useState(false); - const [checked, setChecked] = React.useState(location.state?.cidr ? false : true); + const [mode, setMode] = React.useState(location.state?.cidr ? "manual" : "auto"); + + const checked = mode === "auto"; const dispatch = useDispatch(); + function onModeChange(_, newMode) { + if (newMode !== null) { + setMode(newMode); + } + } + function onCancel() { handleClose(); - setChecked(true); + setMode("auto"); setDescription({ value: "", error: false }); - setMask(maskOptions[0]); + setMask(maskOptions?.[0] ?? null); setReverseSearch(false); setSmallestCIDR(false); - setCidr({ value: "", error: false }); + setCidr({ value: "", error: true }); } function onSubmit() { @@ -167,15 +132,15 @@ export default function NewReservation(props) { CIDR_REGEX ); - return cidr ? !regex.test(cidr) : false; + return cidr ? !regex.test(cidr) : true; } React.useEffect(() => { - const descError = description.error - const maskError = checked ? !mask : false - const cidrError = !checked ? cidr.error : false + const descError = description.error; + const maskError = checked ? !mask : false; + const cidrError = !checked ? cidr.error : false; - setInvalidForm( descError || maskError || cidrError); + setInvalidForm(descError || maskError || cidrError); }, [checked, description, mask, cidr]); React.useEffect(() => { @@ -193,240 +158,164 @@ export default function NewReservation(props) { }, [selectedBlock]); return ( -
- - - Create Reservation - - - - - - - Optional -
- Max of 128 characters -
- Can contain alphnumerics -
- Can contain spaces -
- Can contain underscore, hypen, slash and period -
- Cannot start/end with underscore, hypen, slash or period - - } + + + Create Reservation + + + + + - Optional +
- Max of 128 characters +
- Can contain alphnumerics +
- Can contain spaces +
- Can contain underscore, hypen, slash and period +
- Cannot start/end with underscore, hypen, slash or period + + } + > + onDescriptionChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "80%" }} + /> +
+ + - onDescriptionChange(event)} - inputProps={{ spellCheck: false }} - sx={{ width: "100%" }} - /> -
- + - - setChecked(true)} - value={true} - /> - - Auto + Manual + + + + + {mode === "auto" ? ( + + option.name} + inputValue={maskInput} + onInputChange={(_, newInputValue) => setMaskInput(newInputValue)} + value={mask} + onChange={(_, newValue) => setMask(newValue)} + sx={{ width: '7ch', flexShrink: 0 }} + ListboxProps={{ + style: { maxHeight: "15rem" }, + position: "bottom-start" }} - > - - option.name} - inputValue={maskInput} - onInputChange={(event, newInputValue) => setMaskInput(newInputValue)} - value={mask} - onChange={(event, newValue) => setMask(newValue)} - sx={{ width: '8ch' }} - ListboxProps={{ - style: { - maxHeight: "15rem" - }, - position: "bottom-start" - }} - renderInput={(params) => ( - - )} + renderInput={(params) => ( + - - - setReverseSearch(prev => !prev)} - /> - } - label="Reverse Search" - sx={{ pb: 1 }} - /> - setSmallestCIDR(prev => !prev)} - /> - } - label="Smallest CIDR" - /> - - - - - - setChecked(false)} - value={false} + )} + /> + + setReverseSearch(prev => !prev)} + /> + } + label={Reverse Search} + sx={{ mb: 0.5 }} /> - - - - - - Must be in valid CIDR notation format -
- Example: 1.2.3.4/5 - - } - > -
- onCidrChange(event)} - sx={{ - width: '21ch', - }} - /> -
-
-
-
+ setSmallestCIDR(prev => !prev)} + /> + } + label={Smallest CIDR} + /> +
+ ) : ( + + + - Must be in valid CIDR notation format +
- Example: 1.2.3.4/5 + + } + > + onCidrChange(event)} + inputProps={{ spellCheck: false }} + sx={{ width: "20ch" }} + /> +
+ )}
-
- - - - Create - - -
-
+ + + + + + + ); } diff --git a/ui/src/features/drawer/drawer.jsx b/ui/src/features/drawer/drawer.jsx index 226ef66c..fab7a322 100644 --- a/ui/src/features/drawer/drawer.jsx +++ b/ui/src/features/drawer/drawer.jsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useSelector, useDispatch } from 'react-redux'; import { useMsal } from "@azure/msal-react"; -import { InteractionStatus, InteractionRequiredAuthError, BrowserAuthError } from "@azure/msal-browser"; +import { InteractionStatus } from "@azure/msal-browser"; import { useSnackbar } from "notistack"; @@ -15,7 +15,7 @@ import { plural, singular } from 'pluralize'; import { Routes, Route, Link, Navigate, useNavigate } from "react-router"; import { callMsGraph, callMsGraphPhoto } from "../../msal/graph"; -import { msalInstance } from "../../index"; +import { getApiToken } from "../../msal/tokenService"; import { AppBar, @@ -113,7 +113,7 @@ import { selectEndpoints } from "../ipam/ipamSlice"; -import { apiRequest } from "../../msal/authConfig"; + const Search = styled("div")(({ theme }) => ({ display: "flex", @@ -173,7 +173,7 @@ const Search = styled("div")(({ theme }) => ({ // })); export default function NavDrawer() { - const { instance, accounts, inProgress } = useMsal(); + const { instance, inProgress } = useMsal(); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); @@ -185,6 +185,7 @@ export default function NavDrawer() { const [settingsOpen, setSettingsOpen] = React.useState(false); const [aboutOpen, setAboutOpen] = React.useState(false); const [searchData, setSearchData] = React.useState([]); + const [dataLoaded, setDataLoaded] = React.useState(false); const [searchInput, setSearchInput] = React.useState(''); const [searchValue, setSearchValue] = React.useState(null); @@ -355,24 +356,37 @@ export default function NavDrawer() { ]; React.useEffect(() => { - if (!graphData) { - (async() => { - try { - const graphResponse = await callMsGraph(); - const photoResponse = await callMsGraphPhoto(); - await dispatch(setUserId(graphResponse.userPrincipalName)); - setGraphPhoto(photoResponse); - setGraphData(graphResponse); - } catch (e) { - console.log("ERROR"); - console.log("------------------"); - console.log(e); - console.log("------------------"); - // enqueueSnackbar(e.message, { variant: "error" }); - } - })(); + if (graphData || inProgress !== InteractionStatus.None) { + return; } - }, [graphData, dispatch]); + + let cancelled = false; + + (async () => { + try { + const graphResponse = await callMsGraph(); + const photoResponse = await callMsGraphPhoto(); + + if (cancelled) { + return; + } + + await dispatch(setUserId(graphResponse.userPrincipalName)); + setGraphPhoto(photoResponse); + setGraphData(graphResponse); + } catch (e) { + console.log("ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + // enqueueSnackbar(e.message, { variant: "error" }); + } + })(); + + return () => { + cancelled = true; + }; + }, [graphData, inProgress, dispatch]); React.useEffect(() => { // Handler to call on window resize @@ -526,6 +540,10 @@ export default function NavDrawer() { } setSearchData(newSearchData); + + if(vNets !== null && vHubs !== null && endpoints !== null) { + setDataLoaded(true); + } }, [vNets, vHubs, subnets, endpoints]); const filterOptions = createFilterOptions({ @@ -613,7 +631,6 @@ export default function NavDrawer() { ); function RequestToken() { - // Check if there's already an interaction in progress before starting new one if (inProgress !== InteractionStatus.None) { enqueueSnackbar("Authentication in progress, please wait and try again", { variant: "info" }); return; @@ -621,37 +638,10 @@ export default function NavDrawer() { (async () => { try { - const accounts = msalInstance.getAllAccounts(); - - if (accounts.length === 0) { - throw new Error("No user accounts found. Please login first."); - } - - const tokenRequest = { - ...apiRequest, - account: accounts[0] - }; - - let token; - try { - const response = await msalInstance.acquireTokenSilent(tokenRequest); - token = response.accessToken; - } catch (e) { - if (e instanceof InteractionRequiredAuthError || - (e instanceof BrowserAuthError && e.errorCode === "monitor_window_timeout")) { - - await msalInstance.acquireTokenRedirect(tokenRequest); - return; // Exit since redirect will happen - } else { - throw e; - } - } - - if (token) { - navigator.clipboard.writeText(token); - handleMenuClose(); - enqueueSnackbar('Token copied to clipboard!', { variant: 'success' }); - } + const token = await getApiToken(); + navigator.clipboard.writeText(token); + handleMenuClose(); + enqueueSnackbar('Token copied to clipboard!', { variant: 'success' }); } catch (e) { console.log("ERROR REQUESTING TOKEN"); console.log("------------------"); @@ -897,7 +887,7 @@ export default function NavDrawer() { options={searchData ? orderBy(searchData, 'category', 'asc') : []} groupBy={(option) => option.category} getOptionLabel={(option) => option.phrase} - disabled={searchData.length > 0 ? false : true} + disabled={!dataLoaded || searchData.length === 0} inputValue={searchInput} onInputChange={(event, newSearchInput) => { setSearchInput(newSearchInput); @@ -911,7 +901,7 @@ export default function NavDrawer() { renderInput={(params) => ( 0 ? "Search..." : "Loading..."} + placeholder={dataLoaded ? (searchData.length > 0 ? "Search..." : "No Resources...") : "Loading..."} fullWidth variant="standard" InputProps={{ diff --git a/ui/src/features/drawer/utils/about.jsx b/ui/src/features/drawer/utils/about.jsx index bdeb504d..6c2c414d 100644 --- a/ui/src/features/drawer/utils/about.jsx +++ b/ui/src/features/drawer/utils/about.jsx @@ -5,7 +5,7 @@ import * as React from "react"; // import { useSnackbar } from "notistack"; -import Draggable from 'react-draggable'; +import DraggablePaper from '../../../global/DraggablePaper'; import { Box, @@ -17,7 +17,6 @@ import { Typography, // ToggleButton, // ToggleButtonGroup, - Paper } from "@mui/material"; // import { @@ -36,21 +35,6 @@ import { // import { updateMe } from "../ipam/ipamAPI"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function About(props) { const { open, handleClose } = props; diff --git a/ui/src/features/drawer/utils/refresh.jsx b/ui/src/features/drawer/utils/refresh.jsx index 53cdfa4f..15a6cd3c 100644 --- a/ui/src/features/drawer/utils/refresh.jsx +++ b/ui/src/features/drawer/utils/refresh.jsx @@ -1,6 +1,9 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { useMsal } from "@azure/msal-react"; +import { InteractionStatus } from "@azure/msal-browser"; + import { getRefreshInterval, refreshAllAsync, @@ -10,15 +13,25 @@ import { function Refresh() { const intervalAll = React.useRef(null); const intervalMe = React.useRef(null); - const refreshAllRef = React.useRef(); + const refreshAllRef = React.useRef(null); const refreshMeRef = React.useRef(null); const refreshLoadedRef = React.useRef(false); + const inProgressRef = React.useRef(InteractionStatus.None); const refreshInterval = useSelector(getRefreshInterval); const dispatch = useDispatch(); + const { inProgress } = useMsal(); + + React.useEffect(() => { + inProgressRef.current = inProgress; + }, [inProgress]); refreshAllRef.current = React.useCallback(() => { + if (inProgressRef.current !== InteractionStatus.None) { + return; + } + (async() => { try { await dispatch(refreshAllAsync()); @@ -32,11 +45,15 @@ function Refresh() { }, [dispatch]); refreshMeRef.current = React.useCallback(() => { + if (inProgressRef.current !== InteractionStatus.None) { + return; + } + (async() => { try { await dispatch(getMeAsync()); } catch (e) { - console.log("REFRESM ME ERROR"); + console.log("REFRESH ME ERROR"); console.log("------------------"); console.log(e); console.log("------------------"); @@ -65,12 +82,17 @@ function Refresh() { } }, []); - React.useEffect(()=>{ - if(!refreshLoadedRef.current) { + React.useEffect(() => { + // Wait until MSAL is idle (inProgress === None) before triggering the + // initial data fetch. After a redirect-based re-auth, inProgress starts + // as "handleRedirect" and only transitions to "none" once the auth code + // exchange is complete. Without this guard the initial call would bail + // out (because refreshMeRef checks inProgressRef) and never retry. + if (!refreshLoadedRef.current && inProgress === InteractionStatus.None) { refreshLoadedRef.current = true; refreshMeRef.current(); } - }, []); + }, [inProgress]); React.useEffect(()=>{ const env = { ...import.meta.env, ...window['env'] } diff --git a/ui/src/features/drawer/utils/userSettings.jsx b/ui/src/features/drawer/utils/userSettings.jsx index 94c46f19..736a049c 100644 --- a/ui/src/features/drawer/utils/userSettings.jsx +++ b/ui/src/features/drawer/utils/userSettings.jsx @@ -5,7 +5,10 @@ import { isEqual } from 'lodash'; import { useSnackbar } from "notistack"; -import Draggable from 'react-draggable'; +import { useMsal } from "@azure/msal-react"; +import { InteractionStatus } from "@azure/msal-browser"; + +import DraggablePaper from '../../../global/DraggablePaper'; import { Box, @@ -17,7 +20,6 @@ import { Typography, ToggleButton, ToggleButtonGroup, - Paper } from "@mui/material"; import { @@ -25,8 +27,6 @@ import { DarkModeOutlined, } from "@mui/icons-material"; -import LoadingButton from '@mui/lab/LoadingButton'; - import { getMeAsync, getRefreshInterval, @@ -36,21 +36,6 @@ import { import { updateMe } from "../../ipam/ipamAPI"; -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - - ); -} - export default function UserSettings(props) { const { open, handleClose } = props; @@ -66,6 +51,8 @@ export default function UserSettings(props) { const refreshInterval = useSelector(getRefreshInterval); const dispatch = useDispatch(); + const { inProgress } = useMsal(); + const pendingRefreshRef = React.useRef(false); const changed = React.useMemo(() => { const currentState = { @@ -100,6 +87,13 @@ export default function UserSettings(props) { } }, [open, prevOpen, darkModeSetting, refreshInterval]); + React.useEffect(() => { + if (pendingRefreshRef.current && inProgress === InteractionStatus.None) { + pendingRefreshRef.current = false; + dispatch(getMeAsync()); + } + }, [inProgress, dispatch]); + function onSubmit() { var body = [ { "op": "replace", "path": "/apiRefresh", "value": refreshValue }, @@ -117,7 +111,11 @@ export default function UserSettings(props) { apiRefresh: refreshValue } ); - dispatch(getMeAsync()); + if (inProgress === InteractionStatus.None) { + dispatch(getMeAsync()); + } else { + pendingRefreshRef.current = true; + } handleClose(); } catch (e) { console.log("ERROR"); @@ -145,7 +143,7 @@ export default function UserSettings(props) { - + diff --git a/ui/src/features/ipam/ipamAPI.jsx b/ui/src/features/ipam/ipamAPI.jsx index d2c42bdc..cfbd8d4d 100644 --- a/ui/src/features/ipam/ipamAPI.jsx +++ b/ui/src/features/ipam/ipamAPI.jsx @@ -1,51 +1,22 @@ import axios from 'axios'; -import { InteractionRequiredAuthError, BrowserAuthError } from "@azure/msal-browser"; -import { msalInstance } from '../../index'; -import { apiRequest } from '../../msal/authConfig'; +import { getApiToken } from '../../msal/tokenService'; import { getEngineURL } from '../../global/globals'; const ENGINE_URL = getEngineURL(); -async function generateToken() { - const accounts = msalInstance.getAllAccounts(); - - if (accounts.length === 0) { - throw new Error("No user accounts found. Please login first."); - } - - const tokenRequest = { - ...apiRequest, - account: accounts[0] - }; - - try { - const response = await msalInstance.acquireTokenSilent(tokenRequest); - return response.accessToken; - } catch (e) { - if (e instanceof InteractionRequiredAuthError || - (e instanceof BrowserAuthError && e.errorCode === "monitor_window_timeout")) { - - await msalInstance.acquireTokenRedirect(tokenRequest); - return null; - } else { - throw e; - } - } -} - const api = axios.create(); api.interceptors.request.use( async config => { - const token = await generateToken(); + const token = await getApiToken(); config.headers['Authorization'] = `Bearer ${token}`; return config; }, error => { - Promise.reject(error) + return Promise.reject(error); }); api.interceptors.response.use( @@ -54,11 +25,11 @@ api.interceptors.response.use( console.log("ERROR CALLING IPAM API"); console.log(error); - if(error.response) { + if (error.response) { return Promise.reject(new Error(error.response.data.error)); - } else { - return Promise.reject(error); } + + return Promise.reject(error); }); export function fetchSpaces(utilization = false) { diff --git a/ui/src/features/ipam/ipamSlice.jsx b/ui/src/features/ipam/ipamSlice.jsx index c47d43cd..c95c4bf1 100644 --- a/ui/src/features/ipam/ipamSlice.jsx +++ b/ui/src/features/ipam/ipamSlice.jsx @@ -12,6 +12,7 @@ import { createBlock, updateBlock, deleteBlock, + replaceBlockNetworks, createBlockExternal, updateBlockExternal, deleteBlockExternal, @@ -167,6 +168,19 @@ export const deleteBlockAsync = createAsyncThunk( } ); +export const replaceBlockNetworksAsync = createAsyncThunk( + 'ipam/replaceBlockNetworks', + async (args, { rejectWithValue }) => { + try { + const response = await replaceBlockNetworks(args.space, args.block, args.body); + + return response; + } catch (err) { + return rejectWithValue(err); + } + } +); + export const createBlockExternalAsync = createAsyncThunk( 'ipam/createBlockExternal', async (args, { rejectWithValue }) => { @@ -575,16 +589,37 @@ export const ipamSlice = createSlice({ // SnackbarUtils.error(`Error fetching user settings (${action.error.message})`); throw action.payload; }) + .addCase(replaceBlockNetworksAsync.fulfilled, (state, action) => { + const spaceName = action.meta.arg.space; + const blockName = action.meta.arg.block; + + const spaceIndex = state.spaces.findIndex((space) => space.name === spaceName); + + if(spaceIndex > -1) { + const blockIndex = state.spaces[spaceIndex].blocks.findIndex((block) => block.name === blockName); + + if(blockIndex > -1) { + state.spaces[spaceIndex].blocks[blockIndex].vnets = action.payload; + } + } + }) + .addCase(replaceBlockNetworksAsync.rejected, (state, action) => { + console.log("replaceBlockNetworksAsync Rejected"); + console.log(action); + throw action.payload; + }) .addCase(createBlockExternalAsync.fulfilled, (state, action) => { const spaceName = action.meta.arg.space; const spaceIndex = state.spaces.findIndex((x) => x.name === spaceName); const blockName = action.meta.arg.block; const newExternal = action.payload - const blockIndex = state.spaces[spaceIndex].blocks.findIndex((block) => block.name === blockName); + if(spaceIndex > -1) { + const blockIndex = state.spaces[spaceIndex].blocks.findIndex((block) => block.name === blockName); - if(blockIndex > -1) { - state.spaces[spaceIndex].blocks[blockIndex].externals.push(newExternal); + if(blockIndex > -1) { + state.spaces[spaceIndex].blocks[blockIndex].externals.push(newExternal); + } } }) .addCase(createBlockExternalAsync.rejected, (state, action) => { @@ -792,7 +827,7 @@ export const ipamSlice = createSlice({ const subnets = vnets.map((vnet) => { var subnetArray = []; - + vnet.subnets.forEach((subnet) => { const subnetDetails = { name: subnet.name, @@ -871,7 +906,7 @@ export const ipamSlice = createSlice({ const subnets = vNetData.map((vnet) => { var subnetArray = []; - + vnet.subnets.forEach((subnet) => { const subnetDetails = { name: subnet.name, @@ -959,7 +994,7 @@ export const ipamSlice = createSlice({ const subnets = vNetData.map((vnet) => { var subnetArray = []; - + vnet.subnets.forEach((subnet) => { const subnetDetails = { name: subnet.name, @@ -993,8 +1028,10 @@ export const ipamSlice = createSlice({ } if(action.payload[3].status === 'fulfilled') { - const endpoints = action.payload[3].value.map((endpoint) => { - endpoint.uniqueId = `${endpoint.id}@$${endpoint.private_ip}` + const endpoints = action.payload[3].value.map((endpoint, index) => { + // Use index as fallback when private_ip is null to ensure uniqueness + const ipPart = endpoint.private_ip ?? `idx${index}`; + endpoint.uniqueId = `${endpoint.id}@$${ipPart}`; return endpoint; }); @@ -1176,6 +1213,41 @@ export const selectUpdatedNetworks = createSelector( } ); +// ============================================================================ +// Drill-Down Parent Selectors +// Pre-computed Sets for O(1) lookups used by DrillDownCellRenderer +// ============================================================================ + +export const selectParentSpaceNames = createSelector( + [selectBlocks], + (blocks) => new Set(blocks?.map((b) => b.parent_space) ?? []) +); + +export const selectBlocksWithVNets = createSelector( + [selectVNets], + (vnets) => new Set(vnets?.flatMap((v) => v.parent_block ?? []) ?? []) +); + +export const selectBlocksWithVHubs = createSelector( + [selectVHubs], + (vhubs) => new Set(vhubs?.flatMap((v) => v.parent_block ?? []) ?? []) +); + +export const selectParentVNetNames = createSelector( + [selectSubnets], + (subnets) => new Set(subnets?.map((s) => s.vnet_name).filter(Boolean) ?? []) +); + +export const selectParentSubnetNames = createSelector( + [selectEndpoints], + (endpoints) => new Set(endpoints?.map((e) => e.subnet_name).filter(Boolean) ?? []) +); + +export const selectParentNetworkNames = createSelector( + [selectEndpoints], + (endpoints) => new Set(endpoints?.map((e) => e.vnet_name).filter(Boolean) ?? []) +); + const getSettingName = (_, settingName) => settingName; export const selectViewSetting = createSelector( diff --git a/ui/src/features/tabs/adminTabs.jsx b/ui/src/features/tabs/adminTabs.jsx index 0ecf808b..be3ffb0b 100644 --- a/ui/src/features/tabs/adminTabs.jsx +++ b/ui/src/features/tabs/adminTabs.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link, useLocation } from "react-router"; -import PropTypes from 'prop-types'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -30,12 +29,6 @@ function TabPanel(props) { ); } -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, -}; - function a11yProps(index) { return { id: `simple-tab-${index}`, diff --git a/ui/src/features/tabs/analyzeTabs.jsx b/ui/src/features/tabs/analyzeTabs.jsx index 32f6ec32..a7ebf1ba 100644 --- a/ui/src/features/tabs/analyzeTabs.jsx +++ b/ui/src/features/tabs/analyzeTabs.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link, useLocation } from "react-router"; -import PropTypes from 'prop-types'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -29,12 +28,6 @@ function TabPanel(props) { ); } -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, -}; - function a11yProps(index) { return { id: `simple-tab-${index}`, diff --git a/ui/src/features/tabs/config/discoverConfig.jsx b/ui/src/features/tabs/config/discoverConfig.jsx index 94f46a7a..b877b20e 100644 --- a/ui/src/features/tabs/config/discoverConfig.jsx +++ b/ui/src/features/tabs/config/discoverConfig.jsx @@ -1,93 +1,49 @@ -import { - Box, - LinearProgress, - Tooltip -} from "@mui/material"; - -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; - import { selectSpaces, selectBlocks, - // selectVNets, selectUpdatedVNets, - // selectVHubs, selectUpdatedVHubs, - // selectSubnets, selectUpdatedSubnets, - // selectEndpoints, - selectUpdatedEndpoints + selectUpdatedEndpoints, + selectParentSpaceNames, + selectBlocksWithVNets, + selectBlocksWithVHubs, + selectParentVNetNames, + selectParentSubnetNames, + selectParentNetworkNames } from '../../ipam/ipamSlice'; -import NumberFilter from '@inovua/reactdatagrid-community/NumberFilter' +import InfoCellRenderer from '../../DiscoverTable/Utils/InfoCellRenderer'; +import ProgressCellRenderer from '../../DiscoverTable/Utils/ProgressCellRenderer'; +import DrillDownCellRenderer from '../../DiscoverTable/Utils/DrillDownCellRenderer'; -function renderProgress(value) { - return ( - - = 0 && value <= 70 - ? "success" - : value > 70 && value < 90 - ? "warning" - : value >= 90 - ? "error" - : "info" - } - /> - - ); +/** + * Value formatter for N/A fallback on empty values + */ +function naValueFormatter(params) { + return params.value || "N/A"; } -function infoCell(value, message, color) { - return ( - - {value} - - - - - - - ); -} +// ============================================================================ +// Filter Configurations +// ============================================================================ + +// Number filter params for utilization columns (0-100 range with inRange default) +const utilizationFilterParams = { + filterOptions: ['inRange', 'equals', 'lessThan', 'greaterThan', 'lessThanOrEqual', 'greaterThanOrEqual'], + defaultOption: 'inRange', + inRangeInclusive: true, +}; + +// Number filter params for count columns (gte 0 default) +const countFilterParams = { + filterOptions: ['greaterThanOrEqual', 'equals', 'lessThan', 'greaterThan', 'lessThanOrEqual', 'inRange'], + defaultOption: 'greaterThanOrEqual', +}; + +// ============================================================================ +// Spaces Configuration +// ============================================================================ export const spaces = { config: { @@ -97,18 +53,40 @@ export const spaces = { idProp: "name" }, columns: [ - { name: "name", header: "Space Name", type: "string", flex: 0.85, visible: true }, - { name: "utilization", header: "Utilization", type: "number", flex: 0.5, filterEditor: NumberFilter, render: ({value}) => renderProgress(value), visible: true }, - { name: "desc", header: "Description", type: "string", flex: 1.00, visible: true }, - { name: "size", header: "Total IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "used", header: "Allocated IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'utilization', operator: 'inrange', type: 'number', value: { start: 0, end: 100 } }, - { name: 'desc', operator: 'contains', type: 'string', value: '' }, - { name: 'size', operator: 'gte', type: 'number', value: 0 }, - { name: 'used', operator: 'gte', type: 'number', value: 0 } + { + field: "name", + headerName: "Space Name", + flex: 0.85, + cellRenderer: DrillDownCellRenderer, + cellRendererParams: { + targets: [ + { label: 'Blocks', path: '/discover/block', filterField: 'parent_space', hasChildrenSelector: selectParentSpaceNames } + ] + } + }, + { + field: "utilization", + headerName: "Utilization", + flex: 0.5, + filter: 'agNumberColumnFilter', + filterParams: utilizationFilterParams, + cellRenderer: ProgressCellRenderer + }, + { field: "desc", headerName: "Description", flex: 1.00 }, + { + field: "size", + headerName: "Total IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { + field: "used", + headerName: "Allocated IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, ], detailsMap: { showProgress: true, @@ -122,6 +100,10 @@ export const spaces = { } }; +// ============================================================================ +// Blocks Configuration +// ============================================================================ + export const blocks = { config: { title: "Block", @@ -130,20 +112,42 @@ export const blocks = { idProp: "id" }, columns: [ - { name: "name", header: "Block Name", type: "string", flex: 0.85, visible: true }, - { name: "utilization", header: "Utilization", type: "number", flex: 0.5, filterEditor: NumberFilter, render: ({value}) => renderProgress(value), visible: true }, - { name: "parent_space", header: "Space", type: "string", flex: 0.85, visible: true }, - { name: "size", header: "Total IP's", type: "number", flex: 0.4, filterEditor: NumberFilter, visible: true }, - { name: "used", header: "Allocated IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "cidr", header: "CIDR Block", type: "string", flex: 0.50, visible: true }, - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'utilization', operator: 'inrange', type: 'number', value: { start: 0, end: 100 } }, - { name: 'parent_space', operator: 'contains', type: 'string', value: '' }, - { name: 'size', operator: 'gte', type: 'number', value: 0 }, - { name: 'used', operator: 'gte', type: 'number', value: 0 }, - { name: 'cidr', operator: 'contains', type: 'string', value: '' } + { + field: "name", + headerName: "Block Name", + flex: 0.85, + cellRenderer: DrillDownCellRenderer, + cellRendererParams: { + targets: [ + { label: 'Virtual Networks', path: '/discover/vnet', filterField: 'parent_block', hasChildrenSelector: selectBlocksWithVNets }, + { label: 'Virtual Hubs', path: '/discover/vhub', filterField: 'parent_block', hasChildrenSelector: selectBlocksWithVHubs } + ] + } + }, + { + field: "utilization", + headerName: "Utilization", + flex: 0.5, + filter: 'agNumberColumnFilter', + filterParams: utilizationFilterParams, + cellRenderer: ProgressCellRenderer + }, + { field: "parent_space", headerName: "Space", flex: 0.85 }, + { + field: "size", + headerName: "Total IP's", + flex: 0.4, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { + field: "used", + headerName: "Allocated IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { field: "cidr", headerName: "CIDR Block", flex: 0.50 }, ], detailsMap: { showProgress: true, @@ -158,6 +162,10 @@ export const blocks = { } }; +// ============================================================================ +// Virtual Networks Configuration +// ============================================================================ + export const vnets = { config: { title: "Virtual Network", @@ -166,26 +174,62 @@ export const vnets = { idProp: "id" }, columns: [ - { name: "name", header: "vNet Name", type: "string", flex: 0.85, visible: true }, - { name: "utilization", header: "Utilization", type: "number", flex: 0.5, filterEditor: NumberFilter, render: ({value}) => renderProgress(value), visible: true }, - { name: "parent_block", header: "Block", type: "array", flex: 0.85, render: ({value}) => value?.join(", ") ?? "", visible: true }, - { name: "resource_group", header: "Resource Group", type: "string", flex: 0.75, visible: false }, - { name: "subscription_name", header: "Subscription Name", type: "string", flex: 0.85, visible: false }, - { name: "subscription_id", header: "Subscription ID", type: "string", flex: 0.85, visible: false }, - { name: "size", header: "Total IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "used", header: "Allocated IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "prefixes", header: "Address Space", type: "array", flex: 0.75, render: ({value}) => value.join(", "), visible: true } - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'utilization', operator: 'inrange', type: 'number', value: { start: 0, end: 100 } }, - { name: 'parent_block', operator: 'contains', type: 'array', value: '' }, - { name: 'resource_group', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_name', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_id', operator: 'contains', type: 'string', value: '' }, - { name: 'size', operator: 'gte', type: 'number', value: 0 }, - { name: 'used', operator: 'gte', type: 'number', value: 0 }, - { name: 'prefixes', operator: 'contains', type: 'array', value: '' } + { + field: "name", + headerName: "vNet Name", + flex: 0.85, + cellRenderer: DrillDownCellRenderer, + cellRendererParams: { + targets: [ + { label: 'Subnets', path: '/discover/subnet', filterField: 'vnet_name', hasChildrenSelector: selectParentVNetNames } + ] + } + }, + { + field: "utilization", + headerName: "Utilization", + flex: 0.5, + filter: 'agNumberColumnFilter', + filterParams: utilizationFilterParams, + cellRenderer: ProgressCellRenderer + }, + { + field: "parent_block", + headerName: "Block", + flex: 0.85, + valueGetter: (params) => { + const value = params.data?.parent_block; + return Array.isArray(value) ? value.join(", ") : ""; + }, + filterValueGetter: (params) => params.data?.parent_block?.join(", ") ?? "" + }, + { field: "resource_group", headerName: "Resource Group", flex: 0.75, hide: true }, + { field: "subscription_name", headerName: "Subscription Name", flex: 0.85, hide: true }, + { field: "subscription_id", headerName: "Subscription ID", flex: 0.85, hide: true }, + { + field: "size", + headerName: "Total IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { + field: "used", + headerName: "Allocated IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { + field: "prefixes", + headerName: "Address Space", + flex: 0.75, + valueGetter: (params) => { + const value = params.data?.prefixes; + return Array.isArray(value) ? value.join(", ") : ""; + }, + filterValueGetter: (params) => params.data?.prefixes?.join(", ") ?? "" + } ], detailsMap: { showProgress: true, @@ -206,6 +250,10 @@ export const vnets = { } }; +// ============================================================================ +// Subnets Configuration +// ============================================================================ + export const subnets = { config: { title: "Subnet", @@ -214,26 +262,52 @@ export const subnets = { idProp: "id" }, columns: [ - { name: "name", header: "Subnet Name", type: "String", flex: 0.85, visible: true }, - { name: "utilization", header: "Utilization", type: "number", flex: 0.5, filterEditor: NumberFilter, render: ({value}) => renderProgress(value), visible: true }, - { name: "vnet_name", header: "Parent vNet", type: "string", flex: 0.85, visible: true }, - { name: "resource_group", header: "Resource Group", type: "string", flex: 0.75, visible: false }, - { name: "subscription_name", header: "Subscription Name", type: "string", flex: 0.75, visible: false }, - { name: "subscription_id", header: "Subscription ID", type: "String", flex: 0.75, visible: false }, - { name: "size", header: "Total IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "used", header: "Assigned IP's", type: "number", flex: 0.45, filterEditor: NumberFilter, visible: true }, - { name: "prefix", header: "Address Space", type: "string", flex: 0.50, visible: true }, - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'utilization', operator: 'inrange', type: 'number', value: { start: 0, end: 100 } }, - { name: 'vnet_name', operator: 'contains', type: 'string', value: '' }, - { name: 'resource_group', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_name', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_id', operator: 'contains', type: 'string', value: '' }, - { name: 'size', operator: 'gte', type: 'number', value: 0 }, - { name: 'used', operator: 'gte', type: 'number', value: 0 }, - { name: 'prefix', operator: 'contains', type: 'string', value: '' } + { + field: "name", + headerName: "Subnet Name", + flex: 0.85, + cellRenderer: DrillDownCellRenderer, + cellRendererParams: { + targets: [ + { + label: 'Endpoints', + path: '/discover/endpoint', + filterField: [ + { field: 'vnet_name', valueFrom: 'vnet_name' }, + { field: 'subnet_name', valueFrom: 'name' } + ], + hasChildrenSelector: selectParentSubnetNames + } + ] + } + }, + { + field: "utilization", + headerName: "Utilization", + flex: 0.5, + filter: 'agNumberColumnFilter', + filterParams: utilizationFilterParams, + cellRenderer: ProgressCellRenderer + }, + { field: "vnet_name", headerName: "Parent vNet", flex: 0.85 }, + { field: "resource_group", headerName: "Resource Group", flex: 0.75, hide: true }, + { field: "subscription_name", headerName: "Subscription Name", flex: 0.75, hide: true }, + { field: "subscription_id", headerName: "Subscription ID", flex: 0.75, hide: true }, + { + field: "size", + headerName: "Total IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { + field: "used", + headerName: "Assigned IP's", + flex: 0.45, + filter: 'agNumberColumnFilter', + filterParams: countFilterParams + }, + { field: "prefix", headerName: "Address Space", flex: 0.50 }, ], detailsMap: { showProgress: true, @@ -254,6 +328,10 @@ export const subnets = { } }; +// ============================================================================ +// Virtual Hubs Configuration +// ============================================================================ + export const vhubs = { config: { title: "Virtual Hub", @@ -262,22 +340,41 @@ export const vhubs = { idProp: "id" }, columns: [ - { name: "name", header: "vNet Name", type: "string", flex: 0.6, visible: true }, - { name: "vwan_name", header: "Parent vWAN", type: "string", flex: 0.6, visible: true }, - { name: "parent_block", header: "Block", type: "array", flex: 0.75, render: ({value}) => value?.join(", ") ?? "", visible: true }, - { name: "subscription_name", header: "Subscription Name", type: "string", flex: 0.75, visible: false }, - { name: "subscription_id", header: "Subscription ID", type: "string", flex: 0.75, visible: false }, - { name: "resource_group", header: "Resource Group", type: "string", flex: 0.75, visible: true }, - { name: "prefixes", header: "Address Space", type: "array", flex: 0.35, render: ({value}) => value.toString(), visible: true } - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'vwan_name', operator: 'contains', type: 'string', value: '' }, - { name: 'parent_block', operator: 'contains', type: 'array', value: '' }, - { name: 'subscription_name', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_id', operator: 'contains', type: 'string', value: '' }, - { name: 'resource_group', operator: 'contains', type: 'string', value: '' }, - { name: 'prefixes', operator: 'contains', type: 'array', value: '' } + { + field: "name", + headerName: "vHub Name", + flex: 0.6, + cellRenderer: DrillDownCellRenderer, + cellRendererParams: { + targets: [ + { label: 'Endpoints', path: '/discover/endpoint', filterField: 'vnet_name', hasChildrenSelector: selectParentNetworkNames } + ] + } + }, + { field: "vwan_name", headerName: "Parent vWAN", flex: 0.6 }, + { + field: "parent_block", + headerName: "Block", + flex: 0.75, + valueGetter: (params) => { + const value = params.data?.parent_block; + return Array.isArray(value) ? value.join(", ") : ""; + }, + filterValueGetter: (params) => params.data?.parent_block?.join(", ") ?? "" + }, + { field: "subscription_name", headerName: "Subscription Name", flex: 0.75, hide: true }, + { field: "subscription_id", headerName: "Subscription ID", flex: 0.75, hide: true }, + { field: "resource_group", headerName: "Resource Group", flex: 0.75 }, + { + field: "prefixes", + headerName: "Address Space", + flex: 0.35, + valueGetter: (params) => { + const value = params.data?.prefixes; + return Array.isArray(value) ? value.toString() : ""; + }, + filterValueGetter: (params) => params.data?.prefixes?.toString() ?? "" + } ], detailsMap: { showProgress: false, @@ -297,6 +394,10 @@ export const vhubs = { } }; +// ============================================================================ +// Endpoints Configuration +// ============================================================================ + export const endpoints = { config: { title: "Endpoint", @@ -305,22 +406,38 @@ export const endpoints = { idProp: "uniqueId" }, columns: [ - { name: "name", header: "Endpoint Name", type: "string", flex: 0.75, render: ({value, data}) => data.metadata?.orphaned ? infoCell(value, 'Orphaned Endpoint', 'red') : value, visible: true }, - { name: "vnet_name", header: "Parent vNet", type: "string", flex: 0.75, render: ({value}) => value || "N/A", visible: true }, - { name: "subnet_name", header: "Parent Subnet", type: "string", flex: 0.75, render: ({value}) => value || "N/A", visible: true }, - { name: "resource_group", header: "Resource Group", type: "string", flex: 0.75, visible: true }, - { name: "subscription_name", header: "Subscription Name", type: "string", flex: 0.75, visible: false }, - { name: "subscription_id", header: "Subscription ID", type: "string", flex: 0.75, visible: false }, - { name: "private_ip", header: "Private IP", type: "string", flex: 0.35, render: ({value}) => value || "N/A", visible: true }, - ], - filterSettings: [ - { name: 'name', operator: 'contains', type: 'string', value: '' }, - { name: 'vnet_name', operator: 'contains', type: 'string', value: '' }, - { name: 'subnet_name', operator: 'contains', type: 'string', value: '' }, - { name: 'resource_group', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_name', operator: 'contains', type: 'string', value: '' }, - { name: 'subscription_id', operator: 'contains', type: 'string', value: '' }, - { name: 'private_ip', operator: 'contains', type: 'string', value: '' } + { + field: "name", + headerName: "Endpoint Name", + flex: 0.75, + cellRenderer: InfoCellRenderer, + cellRendererParams: { + condition: (data) => data?.metadata?.orphaned, + message: 'Orphaned Endpoint', + color: 'red' + } + }, + { + field: "vnet_name", + headerName: "Parent Network", + flex: 0.75, + valueFormatter: naValueFormatter + }, + { + field: "subnet_name", + headerName: "Parent Subnet", + flex: 0.75, + valueFormatter: naValueFormatter + }, + { field: "resource_group", headerName: "Resource Group", flex: 0.75 }, + { field: "subscription_name", headerName: "Subscription Name", flex: 0.75, hide: true }, + { field: "subscription_id", headerName: "Subscription ID", flex: 0.75, hide: true }, + { + field: "private_ip", + headerName: "Private IP", + flex: 0.35, + valueFormatter: naValueFormatter + }, ], detailsMap: { showProgress: false, @@ -330,7 +447,7 @@ export const endpoints = { { name: "Endpoint Name", value: "name" }, { name: "Kind", value: "metadata.kind" }, { name: "Type", value: "metadata.type" }, - { name: "Parent vNet", value: "vnet_name" }, + { name: "Parent Network", value: "vnet_name" }, { name: "Parent Subnet", value: "subnet_name" }, { name: "Private IP", value: "private_ip" }, { name: "Public IP", value: "metadata.public_ip" }, diff --git a/ui/src/features/tabs/configTabs.jsx b/ui/src/features/tabs/configTabs.jsx index 6e8a3d4f..1f5edd93 100644 --- a/ui/src/features/tabs/configTabs.jsx +++ b/ui/src/features/tabs/configTabs.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link, useLocation } from "react-router"; -import PropTypes from 'prop-types'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -31,12 +30,6 @@ function TabPanel(props) { ); } -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, -}; - function a11yProps(index) { return { id: `simple-tab-${index}`, diff --git a/ui/src/features/tabs/discoverTabs.jsx b/ui/src/features/tabs/discoverTabs.jsx index 8d033831..9f65652f 100644 --- a/ui/src/features/tabs/discoverTabs.jsx +++ b/ui/src/features/tabs/discoverTabs.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link, useLocation } from "react-router"; -import PropTypes from 'prop-types'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -37,12 +36,6 @@ function TabPanel(props) { ); } -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, -}; - function a11yProps(index) { return { id: `simple-tab-${index}`, diff --git a/ui/src/features/tabs/toolsTabs.jsx b/ui/src/features/tabs/toolsTabs.jsx index 50d812b9..a820cd96 100644 --- a/ui/src/features/tabs/toolsTabs.jsx +++ b/ui/src/features/tabs/toolsTabs.jsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Link, useLocation } from "react-router"; -import PropTypes from 'prop-types'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -29,12 +28,6 @@ function TabPanel(props) { ); } -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired, -}; - function a11yProps(index) { return { id: `simple-tab-${index}`, diff --git a/ui/src/features/tools/generator/generator.jsx b/ui/src/features/tools/generator/generator.jsx index eb6f178a..152070ce 100644 --- a/ui/src/features/tools/generator/generator.jsx +++ b/ui/src/features/tools/generator/generator.jsx @@ -9,6 +9,7 @@ import { isEqual, sortBy, pick } from "lodash"; import { Box, + Button, TextField, Menu, MenuItem, @@ -28,8 +29,6 @@ import { CircularProgress } from "@mui/material"; -import LoadingButton from "@mui/lab/LoadingButton"; - import { MenuOpenOutlined, ContentCopyOutlined, @@ -295,7 +294,6 @@ const Generator = () => { }; function onSubmit() { - console.log("Fetching Next Available..."); (async () => { try { setSending(true); @@ -692,14 +690,14 @@ const Generator = () => { } }} /> - Generate - + diff --git a/ui/src/features/tools/planner/planner.jsx b/ui/src/features/tools/planner/planner.jsx index 3c0d108c..e7c98949 100644 --- a/ui/src/features/tools/planner/planner.jsx +++ b/ui/src/features/tools/planner/planner.jsx @@ -130,11 +130,23 @@ const Planner = () => { if (showAll) { setVNetData(vNets); } else { + // Don't process if blocks haven't loaded yet + if (!blocks) { + setVNetData(null); + return; + } + const data = vNets.reduce((vAcc, vCurr) => { if (vCurr['parent_block'] !== null) { vCurr['parent_block'].forEach((p) => { const block = blocks.find((block) => block.name === p && block['parent_space'] === vCurr['parent_space']); + // Guard against block not being found + if (!block) { + console.warn(`Block not found: ${p} in space ${vCurr['parent_space']} for VNet ${vCurr.name}`); + return; + } + const blockPrefixes = vCurr.prefixes.reduce((bAcc, bCurr) => { if (isSubnetOverlap(bCurr, [block.cidr])) { bAcc.push(bCurr); @@ -159,12 +171,14 @@ const Planner = () => { vAcc.push(temp) } - + return vAcc; }, []); setVNetData(data); } + } else { + setVNetData(null); } }, [blocks, vNets, showAll]); @@ -332,7 +346,7 @@ const Planner = () => { )) : null } - + { + const handle = paperRef.current?.querySelector(HANDLE_SELECTOR); + + // Only initiate drag if the pointer is on the handle element + if (!handle || !handle.contains(e.target)) return; + + // Cancel drag if the pointer is on an element matching the cancel selector + if (e.target.closest(CANCEL_SELECTOR)) return; + + const rect = paperRef.current.getBoundingClientRect(); + const parentRect = paperRef.current.parentElement?.getBoundingClientRect(); + + dragState.current = { + startX: e.clientX, + startY: e.clientY, + offsetX: rect.left - (parentRect?.left ?? 0), + offsetY: rect.top - (parentRect?.top ?? 0), + parentWidth: parentRect?.width ?? window.innerWidth, + parentHeight: parentRect?.height ?? window.innerHeight, + elWidth: rect.width, + elHeight: rect.height, + }; + + e.target.setPointerCapture(e.pointerId); + }, []); + + const handlePointerMove = React.useCallback((e) => { + if (!dragState.current || !paperRef.current) return; + + const { startX, startY, offsetX, offsetY, parentWidth, parentHeight, elWidth, elHeight } = + dragState.current; + + let newX = offsetX + (e.clientX - startX); + let newY = offsetY + (e.clientY - startY); + + // Clamp to parent bounds + newX = Math.max(0, Math.min(newX, parentWidth - elWidth)); + newY = Math.max(0, Math.min(newY, parentHeight - elHeight)); + + paperRef.current.style.transform = `translate(${newX}px, ${newY}px)`; + paperRef.current.style.margin = "0"; + paperRef.current.style.position = "absolute"; + paperRef.current.style.top = "0"; + paperRef.current.style.left = "0"; + }, []); + + const handlePointerUp = React.useCallback(() => { + dragState.current = null; + }, []); + + return ( + + ); +} diff --git a/ui/src/global/grids/ConfigureGrid/ConfigureGrid.jsx b/ui/src/global/grids/ConfigureGrid/ConfigureGrid.jsx new file mode 100644 index 00000000..3efa9941 --- /dev/null +++ b/ui/src/global/grids/ConfigureGrid/ConfigureGrid.jsx @@ -0,0 +1,255 @@ +import React, { useRef, useMemo, useCallback, useEffect } from "react"; +import { AgGridReact } from "ag-grid-react"; +import { AllCommunityModule, ModuleRegistry, themeQuartz } from "ag-grid-community"; +import { useTheme } from '@mui/material/styles'; +import { Box, Typography } from "@mui/material"; +import { SpinnerDotted } from 'spinners-react'; + +// Register AG Grid modules once at module level +ModuleRegistry.registerModules([AllCommunityModule]); + +// ============================================================================ +// Combined Overlay Component (AG Grid v35+) +// ============================================================================ + +// Context for passing reactive overlay config to CombinedOverlay. +// React context changes bypass React.memo, ensuring overlays re-render +// when parent state changes — even when AG Grid itself doesn't propagate +// updated overlayComponentParams to an already-visible overlay. +const OverlayContext = React.createContext(null); + +/** + * CombinedOverlay - Single overlay component for both loading and no-rows states. + * + * Reads the consumer-provided noRowsOverlay component from OverlayContext + * rather than from AG Grid props, so that React context reactivity drives + * re-renders independently of AG Grid's overlay lifecycle. + */ +const CombinedOverlay = React.memo(({ overlayType }) => { + const overlayConfig = React.useContext(OverlayContext); + const NoRowsContent = overlayConfig?.noRowsOverlay; + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + if (overlayType === 'loading') { + return ( + + + + Loading data... + + + ); + } + + // noRows / noMatchingRows + if (NoRowsContent) { + return ; + } + + return ( + + + No data available + + + ); +}); + +CombinedOverlay.displayName = 'CombinedOverlay'; + +/** + * ConfigureGrid - A lightweight AG Grid wrapper for simple configuration panels. + * + * This component is designed for grids that: + * - Display simple data without complex filtering + * - Use click-to-select single row + * - Have external action menus (not in grid header) + * - Show custom "no rows" messages + * + * Used by: block.jsx, space.jsx + * + * For grids that need header menus, view persistence, and filtering, + * use DataGrid instead. + * + * @param {Object} props + * @param {Array} props.rowData - The data to display in the grid + * @param {Array} props.columnDefs - Column definitions + * @param {Function} props.onRowClick - Callback when a row is clicked, receives row data + * @param {Object} props.selectedRow - Currently selected row (controlled selection) + * @param {string} props.idProperty - Property to use as row identifier (default: 'name') + * @param {React.Component} props.noRowsOverlay - Custom no rows overlay component (reactive via OverlayContext) + * @param {boolean} props.isLoading - Show loading overlay (default: false) + * @param {Object} props.gridOptions - Additional AG Grid options + */ +const ConfigureGrid = ({ + rowData, + columnDefs, + onRowClick, + selectedRow, + idProperty = 'name', + noRowsOverlay = null, + isLoading = false, + gridOptions = {}, +}) => { + // Get MUI theme to determine light/dark mode + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + const gridRef = useRef(null); + + // Set theme mode on body for AG Grid CSS variables + useEffect(() => { + document.body.dataset.agThemeMode = isDarkMode ? 'dark' : 'light'; + }, [isDarkMode]); + + // Convert Inovua-style column defs to AG Grid format + const agColumnDefs = useMemo(() => { + return columnDefs.map(col => ({ + field: col.name || col.field, + headerName: col.header || col.headerName, + flex: col.defaultFlex || col.flex || 1, + minWidth: col.minWidth, + maxWidth: col.maxWidth, + sortable: col.sortable !== false, + resizable: col.resizable !== false, + // Disable filtering for simple grids + filter: false, + floatingFilter: false, + })); + }, [columnDefs]); + + // Default column definition + const defaultColDef = useMemo(() => ({ + resizable: true, + sortable: true, + filter: false, + }), []); + + // Row selection configuration - single row only + const rowSelection = useMemo(() => ({ + mode: 'singleRow', + checkboxes: false, + enableClickSelection: true, + }), []); + + // Provide stable row identity so AG Grid preserves scroll position across data updates + const getRowId = useCallback((params) => { + return String(params.data[idProperty]); + }, [idProperty]); + + // Handle row click + const onRowClicked = useCallback((event) => { + if (onRowClick) { + onRowClick(event.data); + } + }, [onRowClick]); + + // Sync external selection state with grid + useEffect(() => { + const gridApi = gridRef.current?.api; + if (!gridApi) return; + + // Deselect all first + gridApi.deselectAll(); + + // If there's a selected row, find and select it + if (selectedRow) { + gridApi.forEachNode((node) => { + if (node.data && node.data[idProperty] === selectedRow[idProperty]) { + node.setSelected(true); + } + }); + } + }, [selectedRow, idProperty, rowData]); + + // Overlay context value — consumed by CombinedOverlay via React context. + // Context changes bypass React.memo, guaranteeing overlay re-renders + // when the consumer's noRowsOverlay reference changes. + const overlayContextValue = useMemo(() => ({ + noRowsOverlay, + }), [noRowsOverlay]); + + // Grid style + const gridStyle = useMemo(() => ({ + height: '100%', + width: '100%', + fontFamily: 'Roboto, Helvetica, Arial, sans-serif', + }), []); + + // Create theme with custom parameters. + // Use withParams() with named color modes for light/dark switching. + // The data-ag-theme-mode attribute (set in useEffect above) controls which mode is active. + // See: https://www.ag-grid.com/react-data-grid/theming-colors/#theme-modes + const gridTheme = useMemo(() => { + const baseParams = { + spacing: 7, // default is 8, reduced for tighter layout + wrapperBorder: false, + wrapperBorderRadius: 0 + }; + + return themeQuartz + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(255, 255, 255, 0.66)' }, 'light') + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(0, 0, 0, 0.2)' }, 'dark'); + }, []); + + return ( + +
+ +
+
+ ); +}; + +ConfigureGrid.displayName = 'ConfigureGrid'; + +export default ConfigureGrid; diff --git a/ui/src/global/grids/ConfigureGrid/index.js b/ui/src/global/grids/ConfigureGrid/index.js new file mode 100644 index 00000000..7907c19a --- /dev/null +++ b/ui/src/global/grids/ConfigureGrid/index.js @@ -0,0 +1,9 @@ +/** + * ConfigureGrid - Lightweight grid for configuration panels + * + * A simple AG Grid wrapper for grids that don't need header menus + * or view persistence. Used by block.jsx, space.jsx, etc. + */ + +export { default } from './ConfigureGrid'; +export { default as ConfigureGrid } from './ConfigureGrid'; diff --git a/ui/src/global/grids/DataGrid/DataGrid.jsx b/ui/src/global/grids/DataGrid/DataGrid.jsx new file mode 100644 index 00000000..73066fc3 --- /dev/null +++ b/ui/src/global/grids/DataGrid/DataGrid.jsx @@ -0,0 +1,1049 @@ +import React, { useState, useRef, useMemo, useCallback, useEffect } from "react"; +import { useSelector, useDispatch } from 'react-redux'; +import { AgGridReact } from "ag-grid-react"; +import { AllCommunityModule, ModuleRegistry, themeQuartz } from "ag-grid-community"; +import { isEmpty, sortBy, compact, map, filter, find } from 'lodash'; +import { + Box, + Menu, + MenuItem, + ListItemIcon, + CircularProgress, + Divider, + Typography, +} from "@mui/material"; +import { + TaskAltOutlined, + CancelOutlined, + ExpandCircleDownOutlined, + FileDownloadOutlined, + FileUploadOutlined, + ReplayOutlined, + ViewColumnOutlined, + ChevronRightOutlined, + CheckOutlined, + FilterListOffOutlined +} from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; +import { useSnackbar } from 'notistack'; +import { SpinnerDotted } from 'spinners-react'; + +import { DataGridContext } from './DataGridContext'; +import { + selectViewSetting, + updateMeAsync, +} from '../../../features/ipam/ipamSlice'; + +// Register AG Grid modules once at module level +ModuleRegistry.registerModules([AllCommunityModule]); + +// Constants +const ACTIONS_COLUMN_FIELD = 'actions'; +const DEFAULT_VIEW_SETTING_KEY = 'defaultGrid'; +const MENU_CLOSE_DELAY = 0; +const SUCCESS_INDICATOR_TIMEOUT = 3000; + +// ============================================================================ +// Column Visibility Menu Component +// ============================================================================ +const ColumnVisibilityMenu = React.memo(({ anchorEl, open, onClose }) => { + const { columnDefs, toggleColumnVisibility, getVisibleColumns, gridRef } = React.useContext(DataGridContext); + const [visibleColumns, setVisibleColumns] = useState([]); + + // Update visible columns when menu opens + useEffect(() => { + if (open) { + setVisibleColumns(getVisibleColumns()); + } + }, [open, getVisibleColumns]); + + // Get columns in their current grid order + const getColumnsInGridOrder = useCallback(() => { + if (!gridRef?.current?.api) { + return filter(columnDefs, col => col.field !== ACTIONS_COLUMN_FIELD); + } + + try { + const columnState = gridRef.current.api.getColumnState(); + if (!columnState) return filter(columnDefs, col => col.field !== ACTIONS_COLUMN_FIELD); + + const dataColumnsInOrder = compact( + map( + sortBy(filter(columnState, state => state.colId !== ACTIONS_COLUMN_FIELD), 'sort'), + state => find(columnDefs, def => def.field === state.colId) + ) + ); + + return dataColumnsInOrder.length > 0 + ? dataColumnsInOrder + : filter(columnDefs, col => col.field !== ACTIONS_COLUMN_FIELD); + } catch (error) { + console.warn('Error getting column order:', error); + return filter(columnDefs, col => col.field !== ACTIONS_COLUMN_FIELD); + } + }, [columnDefs]); + + const dataColumns = useMemo(() => getColumnsInGridOrder(), [getColumnsInGridOrder]); + + const handleColumnToggle = useCallback((field) => { + toggleColumnVisibility(field); + setTimeout(() => setVisibleColumns(getVisibleColumns()), MENU_CLOSE_DELAY); + }, [toggleColumnVisibility, getVisibleColumns]); + + if (!open) return null; + + return ( + + {dataColumns.map((col) => ( + handleColumnToggle(col.field)} + sx={{ + padding: '8px 16px', + display: 'flex', + alignItems: 'center' + }} + > + + + + + {col.headerName || col.field} + + + ))} + + ); +}); + +ColumnVisibilityMenu.displayName = 'ColumnVisibilityMenu'; + +// ============================================================================ +// Header Menu Placeholder Component (renders icon in grid header) +// ============================================================================ +const HeaderMenuPlaceholder = React.memo(() => { + const { saving, sendResults, menuOpen, setMenuOpen, setMenuAnchor } = React.useContext(DataGridContext); + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current) { + setMenuAnchor(containerRef.current); + } + }, [setMenuAnchor]); + + const handleClick = useCallback((event) => { + if (!saving && sendResults === null) { + event.stopPropagation(); + setMenuOpen(prev => !prev); + } + }, [setMenuOpen, saving, sendResults]); + + const handleKeyDown = useCallback((event) => { + if (saving || sendResults !== null) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + setMenuOpen(prev => !prev); + } + }, [saving, sendResults, setMenuOpen]); + + return ( + + {saving && } + {sendResults !== null && !saving && ( + sendResults ? : + )} + {!saving && sendResults === null && } + + ); +}); + +HeaderMenuPlaceholder.displayName = 'HeaderMenuPlaceholder'; + +// ============================================================================ +// Standalone Header Menu Component (outside of AG Grid) +// ============================================================================ +const StandaloneHeaderMenu = React.memo(({ viewSettingKey = DEFAULT_VIEW_SETTING_KEY, extraMenuItems = [] }) => { + const { + saveConfig, + loadConfig, + resetConfig, + gridRef, + menuOpen, + setMenuOpen, + menuAnchor, + viewSetting + } = React.useContext(DataGridContext); + + const [columnMenuOpen, setColumnMenuOpen] = useState(false); + const [columnMenuAnchor, setColumnMenuAnchor] = useState(null); + + const handleMenuToggle = useCallback(() => { + setMenuOpen(prev => !prev); + }, [setMenuOpen]); + + const handleMenuAction = useCallback((action) => { + action(); + setMenuOpen(false); + }, [setMenuOpen]); + + const handleExtraMenuItemClick = useCallback((item) => { + if (item.onClick) { + item.onClick(); + } + if (item.closeMenuAfterClick !== false) { + setMenuOpen(false); + } + }, [setMenuOpen]); + + const handleClearFilters = useCallback(() => { + if (gridRef?.current?.api) { + gridRef.current.api.setFilterModel(null); + } + setMenuOpen(false); + }, [gridRef, setMenuOpen]); + + const handleColumnMenuOpen = useCallback((event) => { + event.stopPropagation(); + setColumnMenuAnchor(event.currentTarget); + setColumnMenuOpen(true); + }, []); + + const handleColumnMenuClose = useCallback(() => { + setColumnMenuOpen(false); + setColumnMenuAnchor(null); + }, []); + + const generateMenuKey = useCallback((item, index) => { + return item.key || `extra-menu-item-${index}`; + }, []); + + if (!menuAnchor) return null; + + return ( + <> + { + if (e.key === 'Tab') { + e.preventDefault(); + handleMenuToggle(); + } + } + }} + slotProps={{ + paper: { + elevation: 0, + sx: { + overflow: 'visible', + filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', + mt: 1.5, + '&::before': { + content: '""', + display: 'block', + position: 'absolute', + top: 0, + right: 28, + width: 10, + height: 10, + bgcolor: 'background.paper', + transform: 'translateY(-50%) rotate(45deg)', + zIndex: 0, + }, + }, + }, + }} + transformOrigin={{ horizontal: 'center', vertical: 'top' }} + anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }} + > + {/* Extra menu items passed from parent */} + {extraMenuItems.map((item, index) => ( + handleExtraMenuItemClick(item)} + disabled={item.disabled} + > + {item.icon && ( + + + + )} + {item.label} + + ))} + + {extraMenuItems.length > 0 && } + + handleMenuAction(loadConfig)} + disabled={!viewSetting || isEmpty(viewSetting)} + > + + + + Load Saved View + + + handleMenuAction(saveConfig)}> + + + + Save Current View + + + handleMenuAction(resetConfig)}> + + + + Reset Default View + + + + + + + + + Clear All Filters + + + + + + + Manage Columns + + + + + + + ); +}); + +StandaloneHeaderMenu.displayName = 'StandaloneHeaderMenu'; + +// ============================================================================ +// Combined Overlay Component (AG Grid v35+) +// ============================================================================ + +// Context for passing reactive overlay config to CombinedOverlay. +// React context changes bypass React.memo, ensuring overlays re-render +// when parent state changes — even when AG Grid itself doesn't propagate +// updated overlayComponentParams to an already-visible overlay. +const OverlayContext = React.createContext(null); + +/** + * CombinedOverlay - Single overlay component for both loading and no-rows states. + * + * Reads the consumer-provided noRowsOverlay component from OverlayContext + * rather than from AG Grid props, so that React context reactivity drives + * re-renders independently of AG Grid's overlay lifecycle. + * + * @param {Object} props - Props from AG Grid + * @param {'loading'|'noRows'|'noMatchingRows'} props.overlayType - Overlay type determined by AG Grid + */ +const CombinedOverlay = React.memo(({ overlayType }) => { + const overlayConfig = React.useContext(OverlayContext); + const NoRowsContent = overlayConfig?.noRowsOverlay; + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + if (overlayType === 'loading') { + return ( + + + + Loading data... + + + ); + } + + // noRows / noMatchingRows + if (NoRowsContent) { + return ; + } + + return ( + + + No data available + + + ); +}); + +CombinedOverlay.displayName = 'CombinedOverlay'; + +// ============================================================================ +// Main DataGrid Component +// ============================================================================ +/** + * DataGrid - A full-featured AG Grid wrapper component for IPAM. + * + * This component provides a standardized grid with: + * - Header dropdown menu with custom actions + * - Column visibility management + * - Save/Load/Reset view configuration (persisted via Redux) + * - Dark/Light theme support + * - Single and multi-row selection + * - Loading overlay + * - Copy-on-double-click (enabled by default) + * + * @param {string} props.viewSettingKey - Required. Unique identifier for view persistence (e.g., 'networks', 'spaces'). + * @param {Array} props.rowData - The data to display in the grid + * @param {Array} props.columnDefs - Column definitions + * @param {Function} props.onRowSelectionChanged - Callback when selection changes (receives array for multiSelect, single row otherwise) + * @param {boolean} props.multiSelect - Enable multi-row selection (default: false) + * @param {boolean} props.checkboxSelect - Show checkboxes and only allow selection via checkbox click (default: false) + * @param {Array} props.extraMenuItems - Additional menu items for the header dropdown + * @param {Array} props.initialSelectedRows - Rows to select initially + * @param {Object} props.rowClassRules - AG Grid row class rules for conditional row styling + * @param {boolean} props.isLoading - Show loading overlay (default: false) + * @param {React.Component} props.noRowsOverlay - Custom component to display when grid has no rows (reactive via OverlayContext) + * @param {boolean} props.copyOnDoubleClick - Copy cell value to clipboard on double-click (default: true) + * @param {string} props.idProperty - Property to use as row ID (default: 'id') + * @param {boolean} props.noBorder - Remove grid wrapper border (default: false) - useful when grid is inside a bordered container + * @param {Function} props.actionsCellRenderer - Custom cell renderer for the actions column (receives params object) + * @param {Function} props.onRowClicked - Custom row click handler (receives event object) - overrides default single-select toggle + * @param {Function} props.onGridReady - Callback when grid is ready, receives { api, columnApi } for programmatic control + */ +const DataGrid = ({ + rowData, + columnDefs, + viewSettingKey = DEFAULT_VIEW_SETTING_KEY, + onRowSelectionChanged, + multiSelect = false, + checkboxSelect = false, + extraMenuItems = [], + initialSelectedRows = [], + rowClassRules = {}, + isLoading = false, + noRowsOverlay = null, + copyOnDoubleClick = true, + idProperty = 'id', + noBorder = false, + actionsCellRenderer = null, + onRowClicked = null, + onGridReady: onGridReadyProp = null, +}) => { + // Get MUI theme to determine light/dark mode + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + const { enqueueSnackbar } = useSnackbar(); + const dispatch = useDispatch(); + + // Get view settings from Redux store + const viewSetting = useSelector(state => selectViewSetting(state, viewSettingKey)); + + // Set theme mode on body for AG Grid CSS variables + useEffect(() => { + document.body.dataset.agThemeMode = isDarkMode ? 'dark' : 'light'; + }, [isDarkMode]); + + // Create theme with optional border removal and compactness + const gridTheme = useMemo(() => { + const baseParams = { + spacing: 7, // default is 8, reduced for tighter layout + ...(noBorder ? { wrapperBorder: false, wrapperBorderRadius: 0 } : {}) + }; + + return themeQuartz + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(255, 255, 255, 0.66)' }, 'light') + .withParams({ ...baseParams, modalOverlayBackgroundColor: 'rgba(0, 0, 0, 0.2)' }, 'dark'); + }, [noBorder]); + + // Overlay context value — consumed by CombinedOverlay via React context. + // Context changes bypass React.memo, guaranteeing overlay re-renders + // when the consumer's noRowsOverlay reference changes. + const overlayContextValue = useMemo(() => ({ + noRowsOverlay, + }), [noRowsOverlay]); + + // Component state + const [saving, setSaving] = useState(false); + const [sendResults, setSendResults] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + const gridRef = useRef(null); + const initialSelectionApplied = useRef(false); + + // Memoized column definitions with actions column appended + const colDefs = useMemo(() => { + const actionsColumn = { + field: ACTIONS_COLUMN_FIELD, + headerName: "", + width: 50, + minWidth: 50, + maxWidth: 50, + sortable: false, + filter: false, + suppressMovable: true, + suppressHeaderMenuButton: true, + suppressSizeToFit: true, + suppressAutoSize: true, + resizable: false, + suppressColumnsToolPanel: true, + headerComponent: HeaderMenuPlaceholder, + cellRenderer: actionsCellRenderer || (() => ''), + cellStyle: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, + }; + + return [...columnDefs, actionsColumn]; + }, [columnDefs, actionsCellRenderer]); + + // Helper function to get data column fields (excluding actions) + const getDataColumnFields = useCallback(() => { + return map(filter(colDefs, col => col.field !== ACTIONS_COLUMN_FIELD), 'field'); + }, [colDefs]); + + // Toggle column visibility + const toggleColumnVisibility = useCallback((field) => { + const gridApi = gridRef.current?.api; + if (!gridApi) return; + + const column = gridApi.getColumn(field); + if (!column) return; + + const isVisible = column.isVisible(); + const dataColumnFields = getDataColumnFields(); + const visibleDataColumns = filter(dataColumnFields, colField => { + const col = gridApi.getColumn(colField); + return col?.isVisible(); + }); + + // Prevent hiding the last visible column + if (isVisible && visibleDataColumns.length === 1) { + return; + } + + gridApi.setColumnsVisible([field], !isVisible); + }, [getDataColumnFields]); + + // Get list of currently visible columns + const getVisibleColumns = useCallback(() => { + const gridApi = gridRef.current?.api; + if (!gridApi) return getDataColumnFields(); + + const dataColumnFields = getDataColumnFields(); + return filter(dataColumnFields, field => { + const column = gridApi.getColumn(field); + return column?.isVisible(); + }); + }, [getDataColumnFields]); + + // ============================================================================ + // Config Management Functions + // ============================================================================ + + const saveConfig = useCallback(async () => { + setSaving(true); + + const gridApi = gridRef.current?.api; + if (gridApi) { + try { + const columnState = gridApi.getColumnState(); + + // Filter out system columns from saved state (actions and selection columns) + const filteredColumnState = columnState.filter(col => + col.colId !== ACTIONS_COLUMN_FIELD && + !col.colId.startsWith('ag-Grid-') + ); + + // Build save payload + const saveData = { + columnState: filteredColumnState, + }; + + const body = [ + { "op": "add", "path": `/views/${viewSettingKey}`, "value": saveData } + ]; + + await dispatch(updateMeAsync({ body })); + setSendResults(true); + } catch (error) { + console.error('Error saving config:', error); + setSendResults(false); + } finally { + setSaving(false); + setTimeout(() => setSendResults(null), SUCCESS_INDICATOR_TIMEOUT); + } + return; + } + + // Fallback if no grid API + setSaving(false); + setSendResults(false); + setTimeout(() => setSendResults(null), SUCCESS_INDICATOR_TIMEOUT); + }, [dispatch, viewSettingKey]); + + const loadConfig = useCallback(() => { + setSaving(true); + + const gridApi = gridRef.current?.api; + + // Check if viewSetting exists and has the expected AG Grid format + if (gridApi && viewSetting) { + try { + // Handle new AG Grid format + if (viewSetting.columnState) { + gridApi.applyColumnState({ + state: viewSetting.columnState, + applyOrder: true, + }); + } + + setSendResults(true); + } catch (error) { + console.error('Error loading config:', error); + // Gracefully handle old format - just ignore and reset + console.warn('View settings may be in old format, ignoring...'); + setSendResults(false); + } + } else { + setSendResults(false); + } + + setSaving(false); + setTimeout(() => setSendResults(null), SUCCESS_INDICATOR_TIMEOUT); + }, [viewSetting]); + + const resetConfig = useCallback(() => { + setSaving(true); + + const gridApi = gridRef.current?.api; + if (gridApi) { + try { + // Build default column state respecting original column definitions + const defaultColumnState = map( + filter(colDefs, col => col.field !== ACTIONS_COLUMN_FIELD), + (colDef) => ({ + colId: colDef.field, + hide: colDef.hide === true, // Respect the hide property from column definition + width: colDef.width || null, + flex: colDef.flex !== undefined ? colDef.flex : 1, + sort: colDef.sort || null, + sortIndex: colDef.sortIndex || null, + aggFunc: null, + pivot: false, + pivotIndex: null, + pinned: colDef.pinned || null, + rowGroup: false, + rowGroupIndex: null + }) + ); + + gridApi.applyColumnState({ + state: defaultColumnState, + applyOrder: true, + defaultState: { sort: null, sortIndex: null } + }); + + gridApi.setFilterModel(null); + + setSendResults(true); + } catch (error) { + console.error('Error during reset:', error); + setSendResults(false); + } + } + + setSaving(false); + setTimeout(() => setSendResults(null), SUCCESS_INDICATOR_TIMEOUT); + }, [colDefs]); + + // ============================================================================ + // Context Value + // ============================================================================ + + const gridContextValue = useMemo(() => ({ + saving, + sendResults, + columnDefs: filter(colDefs, col => col.field !== ACTIONS_COLUMN_FIELD), + toggleColumnVisibility, + getVisibleColumns, + saveConfig, + loadConfig, + resetConfig, + gridRef, + menuOpen, + setMenuOpen, + menuAnchor, + setMenuAnchor, + viewSetting, + }), [ + saving, + sendResults, + colDefs, + toggleColumnVisibility, + getVisibleColumns, + saveConfig, + loadConfig, + resetConfig, + menuOpen, + menuAnchor, + viewSetting, + ]); + + // ============================================================================ + // Grid Configuration + // ============================================================================ + + const defaultColDef = useMemo(() => ({ + initialFlex: 1, + resizable: true, + filter: true, + floatingFilter: true, + }), []); + + const rowSelection = useMemo(() => ({ + mode: multiSelect ? 'multiRow' : 'singleRow', + checkboxes: checkboxSelect, + // For single-select, we handle click selection manually to support toggle behavior + enableClickSelection: multiSelect ? !checkboxSelect : false, + headerCheckbox: checkboxSelect && multiSelect, + }), [multiSelect, checkboxSelect]); + + // ============================================================================ + // Event Handlers + // ============================================================================ + + const onColumnMoved = useCallback(() => { + // Column move logic can be added here if needed + }, []); + + const onColumnResized = useCallback((params) => { + if (!params.finished) return; + + setTimeout(() => { + const gridApi = gridRef.current?.api; + if (!gridApi) return; + + const currentState = gridApi.getColumnState(); + const hasFlexColumns = currentState.some(col => + col.colId !== ACTIONS_COLUMN_FIELD && !col.hide && col.flex > 0 + ); + + // Ensure at least one column has flex to fill remaining space + if (!hasFlexColumns) { + const visibleDataColumns = filter(currentState, col => + col.colId !== ACTIONS_COLUMN_FIELD && !col.hide + ); + + if (visibleDataColumns.length > 0) { + const lastDataColumn = visibleDataColumns[visibleDataColumns.length - 1]; + const updatedState = map(currentState, col => ({ + ...col, + flex: col.colId === lastDataColumn.colId ? 1 : null, + width: null + })); + + gridApi.applyColumnState({ + state: updatedState, + applyOrder: false + }); + } + } + }, MENU_CLOSE_DELAY); + }, []); + + // Initial row selection handling + const initialRowSelection = useMemo(() => { + if (!initialSelectedRows || initialSelectedRows.length === 0) { + return {}; + } + + return initialSelectedRows.reduce((acc, row) => { + const rowId = row[idProperty]; + if (rowId !== undefined && rowId !== null) { + acc[rowId] = true; + } + return acc; + }, {}); + }, [initialSelectedRows, idProperty]); + + // Track if view settings have been applied + const viewSettingsApplied = useRef(false); + + const onGridReady = useCallback((params) => { + const { api } = params; + + // Apply saved view settings when grid is ready + if (viewSetting && viewSetting.columnState && !viewSettingsApplied.current) { + try { + api.applyColumnState({ + state: viewSetting.columnState, + applyOrder: true, + }); + + viewSettingsApplied.current = true; + } catch (error) { + console.error('Error applying saved view settings:', error); + } + } + + // Apply initial selection when grid is ready and has data + if (Object.keys(initialRowSelection).length > 0 && !initialSelectionApplied.current) { + requestAnimationFrame(() => { + api.deselectAll(); + + api.forEachNode((node) => { + const rowId = node.data?.[idProperty]; + if (rowId !== undefined && rowId !== null && initialRowSelection[rowId]) { + node.setSelected(true); + } + }); + + initialSelectionApplied.current = true; + + if (onRowSelectionChanged) { + const selectedRows = api.getSelectedRows(); + onRowSelectionChanged(selectedRows); + } + }); + } + + // Call external onGridReady callback if provided + if (onGridReadyProp) { + onGridReadyProp(params); + } + }, [initialRowSelection, onRowSelectionChanged, idProperty, viewSetting, onGridReadyProp]); + + // Reset selection state when initialSelectedRows changes + useEffect(() => { + initialSelectionApplied.current = false; + }, [initialSelectedRows]); + + // Apply saved view settings when they become available (after initial load from Redux) + useEffect(() => { + const gridApi = gridRef.current?.api; + if (!gridApi || !viewSetting || viewSettingsApplied.current) return; + + // Only apply if we have the new AG Grid format + if (viewSetting.columnState) { + try { + gridApi.applyColumnState({ + state: viewSetting.columnState, + applyOrder: true, + }); + + viewSettingsApplied.current = true; + } catch (error) { + console.error('Error applying saved view settings:', error); + } + } + }, [viewSetting]); + + // Apply selection when initialSelectedRows changes after grid is ready + // Only runs when initialSelectedRows is explicitly provided (not the default empty array) + useEffect(() => { + const gridApi = gridRef.current?.api; + if (!gridApi) return; + + // Only apply if we have rows to select + if (Object.keys(initialRowSelection).length === 0) { + return; + } + + // Apply selection + requestAnimationFrame(() => { + gridApi.deselectAll(); + + gridApi.forEachNode((node) => { + if (node.data?.[idProperty] && initialRowSelection[node.data[idProperty]]) { + node.setSelected(true); + } + }); + + initialSelectionApplied.current = true; + }); + }, [initialRowSelection, idProperty]); + + // Handle selection changes (primarily for multi-select mode) + const onSelectionChanged = useCallback(() => { + // For single-select, we handle selection in onRowClicked to support toggle behavior + if (!multiSelect) return; + + const gridApi = gridRef.current?.api; + if (!gridApi || !onRowSelectionChanged) return; + + const selectedRows = gridApi.getSelectedRows(); + onRowSelectionChanged(selectedRows); + }, [onRowSelectionChanged, multiSelect]); + + // Handle row click for single-select toggle behavior + const handleRowClicked = useCallback((event) => { + // If custom onRowClicked handler is provided, use it instead + if (onRowClicked) { + onRowClicked(event); + return; + } + + // Only handle clicks for single-select mode + if (multiSelect || !onRowSelectionChanged) return; + + const clickedNode = event.node; + const wasSelected = clickedNode.isSelected(); + + if (wasSelected) { + // Clicking a selected row deselects it + clickedNode.setSelected(false); + onRowSelectionChanged(null); + } else { + // Clicking an unselected row selects it + clickedNode.setSelected(true); + onRowSelectionChanged(event.data); + } + }, [multiSelect, onRowSelectionChanged, onRowClicked]); + + // Handle cell double-click - copies value to clipboard by default + const handleCellDoubleClick = useCallback((params) => { + if (copyOnDoubleClick) { + const value = params.value; + if (value !== undefined && value !== null) { + navigator.clipboard.writeText(String(value)); + enqueueSnackbar("Cell value copied to clipboard", { variant: "success" }); + } + } + }, [copyOnDoubleClick, enqueueSnackbar]); + + // Get row ID from data using idProperty + const getRowId = useCallback((params) => { + return params.data?.[idProperty]; + }, [idProperty]); + + // ============================================================================ + // Render + // ============================================================================ + + return ( + + +
+ + +
+
+
+ ); +}; + +DataGrid.displayName = 'DataGrid'; + +export default DataGrid; diff --git a/ui/src/global/grids/DataGrid/DataGridContext.jsx b/ui/src/global/grids/DataGrid/DataGridContext.jsx new file mode 100644 index 00000000..ba34d272 --- /dev/null +++ b/ui/src/global/grids/DataGrid/DataGridContext.jsx @@ -0,0 +1,36 @@ +import { createContext } from 'react'; + +/** + * Context for sharing grid state between DataGrid and its child components. + * This context provides access to grid configuration, column management, + * and menu state. + */ +export const DataGridContext = createContext({ + // Saving state + saving: false, + sendResults: null, + + // Config persistence callbacks + saveConfig: () => {}, + loadConfig: () => {}, + resetConfig: () => {}, + + // Column management + columnDefs: [], + toggleColumnVisibility: () => {}, + getVisibleColumns: () => [], + + // Grid reference + gridRef: null, + + // Menu state management + menuOpen: false, + setMenuOpen: () => {}, + menuAnchor: null, + setMenuAnchor: () => {}, + + // View settings (from Redux or props) + viewSetting: null, +}); + +export default DataGridContext; diff --git a/ui/src/global/grids/DataGrid/index.js b/ui/src/global/grids/DataGrid/index.js new file mode 100644 index 00000000..bb9fe2b8 --- /dev/null +++ b/ui/src/global/grids/DataGrid/index.js @@ -0,0 +1,2 @@ +export { default as DataGrid } from './DataGrid'; +export { DataGridContext } from './DataGridContext'; diff --git a/ui/src/global/grids/index.js b/ui/src/global/grids/index.js new file mode 100644 index 00000000..f7379377 --- /dev/null +++ b/ui/src/global/grids/index.js @@ -0,0 +1,35 @@ +/** + * IPAM Grid Components + * + * Shared AG Grid wrappers for the IPAM application. + * + * Usage: + * + * // Full-featured grid with header menu and view persistence + * import { DataGrid } from '../../global/grids'; + * + * // Simple grids for configuration panels (no menu, no persistence) + * import { ConfigureGrid } from '../../global/grids'; + * + * // Filter utilities + * import { dateFilterParams, createArrayColumnDef } from '../../global/grids'; + */ + +// Main components +export { DataGrid, DataGridContext } from './DataGrid'; +export { ConfigureGrid } from './ConfigureGrid'; + +// Utilities +export { + arrayValueGetter, + arrayFilterValueGetter, + arrayTextFilterComparator, + createArrayColumnDef, + caseInsensitiveFilterParams, + numberFilterParams, + dateFilterParams, + defaultColumnSettings, +} from './utils'; + +// Default export is DataGrid for convenience +export { DataGrid as default } from './DataGrid'; diff --git a/ui/src/global/grids/utils/filterUtils.js b/ui/src/global/grids/utils/filterUtils.js new file mode 100644 index 00000000..cde48f2a --- /dev/null +++ b/ui/src/global/grids/utils/filterUtils.js @@ -0,0 +1,159 @@ +/** + * Shared filter utilities for AG Grid + * + * These utilities provide custom filter logic that can be reused across + * different grid instances in the IPAM application. + */ + +/** + * Custom value getter for array fields. + * Converts arrays to comma-separated strings for filtering. + * + * @param {Object} params - AG Grid value getter params + * @returns {string} - Comma-separated string of array values + */ +export const arrayValueGetter = (params) => { + const value = params.data?.[params.colDef.field]; + if (Array.isArray(value)) { + return value.join(', '); + } + return value || ''; +}; + +/** + * Custom filter value getter for array fields. + * Returns the raw array for custom filtering logic. + * + * @param {Object} params - AG Grid filter value getter params + * @returns {Array|string} - The array value or empty string + */ +export const arrayFilterValueGetter = (params) => { + const value = params.data?.[params.colDef.field]; + return Array.isArray(value) ? value : []; +}; + +/** + * Custom comparator for array fields in text filters. + * Checks if any element in the array contains the filter value. + * + * @param {string} filterValue - The filter input value + * @param {Array} cellValue - The cell's array value + * @returns {boolean} - Whether the filter matches + */ +export const arrayTextFilterComparator = (filterValue, cellValue) => { + if (!filterValue) return true; + if (!Array.isArray(cellValue) || cellValue.length === 0) return false; + + const lowerFilter = filterValue.toLowerCase(); + return cellValue.some(item => + String(item).toLowerCase().includes(lowerFilter) + ); +}; + +/** + * Creates a column definition for an array field with proper filtering. + * + * @param {string} field - The field name + * @param {string} headerName - The header display name + * @param {Object} additionalProps - Additional column properties + * @returns {Object} - Column definition object + */ +export const createArrayColumnDef = (field, headerName, additionalProps = {}) => ({ + field, + headerName, + valueGetter: arrayValueGetter, + filterValueGetter: (params) => { + const value = params.data?.[field]; + // Return joined string for text filter to work properly + return Array.isArray(value) ? value.join(' ') : (value || ''); + }, + ...additionalProps, +}); + +/** + * Custom filter params for case-insensitive text matching. + * Use this for columns that need case-insensitive filtering. + */ +export const caseInsensitiveFilterParams = { + filterOptions: [ + 'contains', + 'notContains', + 'equals', + 'notEqual', + 'startsWith', + 'endsWith', + 'blank', + 'notBlank', + ], + caseSensitive: false, + trimInput: true, +}; + +/** + * Custom filter params for number columns. + */ +export const numberFilterParams = { + filterOptions: [ + 'equals', + 'notEqual', + 'lessThan', + 'lessThanOrEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'inRange', + 'blank', + 'notBlank', + ], + allowedCharPattern: '\\d\\-\\.', + numberParser: (text) => { + return text == null ? null : parseFloat(text); + }, +}; + +/** + * Custom filter params for date columns. + */ +export const dateFilterParams = { + filterOptions: [ + 'equals', + 'notEqual', + 'lessThan', + 'greaterThan', + 'inRange', + 'blank', + 'notBlank', + ], + comparator: (filterLocalDateAtMidnight, cellValue) => { + if (!cellValue) return -1; + + const cellDate = new Date(cellValue); + const filterDate = filterLocalDateAtMidnight; + + if (cellDate < filterDate) return -1; + if (cellDate > filterDate) return 1; + return 0; + }, +}; + +/** + * Default column definitions that can be spread into defaultColDef. + * These provide sensible defaults for most IPAM grid columns. + */ +export const defaultColumnSettings = { + resizable: true, + sortable: true, + filter: true, + floatingFilter: true, + filterParams: caseInsensitiveFilterParams, +}; + +export default { + arrayValueGetter, + arrayFilterValueGetter, + arrayTextFilterComparator, + createArrayColumnDef, + caseInsensitiveFilterParams, + numberFilterParams, + dateFilterParams, + defaultColumnSettings, +}; diff --git a/ui/src/global/grids/utils/index.js b/ui/src/global/grids/utils/index.js new file mode 100644 index 00000000..1bc6b7dd --- /dev/null +++ b/ui/src/global/grids/utils/index.js @@ -0,0 +1,17 @@ +/** + * Shared grid utilities + * + * Filter utilities, column helpers, and other shared functionality + * for AG Grid components. + */ + +export { + arrayValueGetter, + arrayFilterValueGetter, + arrayTextFilterComparator, + createArrayColumnDef, + caseInsensitiveFilterParams, + numberFilterParams, + dateFilterParams, + defaultColumnSettings, +} from './filterUtils'; diff --git a/ui/src/index.css b/ui/src/index.css index ec2585e8..a68d321a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -11,3 +11,59 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +/* AG Grid sharp corners */ +.ag-theme-quartz .ag-root-wrapper, +.ag-root-wrapper { + border-radius: 0; +} + +/* AG Grid overlay scrollbar styling */ + +/* Force scrollbars to overlay (not take up space) */ +.ag-theme-quartz .ag-body-vertical-scroll { + position: absolute !important; + top: 0; + right: 0; + height: 100%; + z-index: 10; + opacity: 0; + transition: opacity 0.3s; +} + +.ag-theme-quartz .ag-body-horizontal-scroll { + position: absolute !important; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + opacity: 0; + transition: opacity 0.3s; +} + +/* Show scrollbars on hover */ +.ag-theme-quartz .ag-root-wrapper:hover .ag-body-vertical-scroll, +.ag-theme-quartz .ag-root-wrapper:hover .ag-body-horizontal-scroll { + opacity: 1; +} + +/* Thin scrollbar */ +.ag-theme-quartz .ag-body-vertical-scroll-viewport, +.ag-theme-quartz .ag-body-horizontal-scroll-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(128, 128, 128, 0.5) transparent; +} + +/* AG Grid hover: darken existing row color instead of replacing it */ +.ag-theme-quartz .ag-row { + --ag-row-hover-color: transparent; +} + +.ag-theme-quartz .ag-row-hover { + filter: brightness(0.96); +} + +[data-ag-theme-mode="dark"] .ag-theme-quartz .ag-row-hover, +.ag-theme-quartz-dark .ag-row-hover { + filter: brightness(1.12); +} diff --git a/ui/src/index.jsx b/ui/src/index.jsx index 0460cdbb..4048000b 100644 --- a/ui/src/index.jsx +++ b/ui/src/index.jsx @@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { store } from './app/store'; import App from './App'; -import reportWebVitals from './reportWebVitals'; import './index.css'; import { PublicClientApplication } from "@azure/msal-browser"; @@ -13,19 +12,47 @@ import { msalConfig } from "./msal/authConfig"; const container = document.getElementById('root'); const root = createRoot(container); +/** + * Detect if the app is loaded inside a hidden iframe (e.g. MSAL silent token acquisition). + * When acquireTokenSilent falls back to an iframe flow, AAD redirects the iframe back to + * the app's origin. Without this guard the full React app boots inside the iframe and every + * component that calls acquireTokenSilent triggers a cascading "block_iframe_reload" error. + * Skipping the render lets MSAL read the iframe hash response without interference. + */ +const isInHiddenIframe = window !== window.parent; + +/** + * MSAL should be instantiated outside of the component tree to prevent it from being re-instantiated on re-renders. + * For more, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-react/docs/getting-started.md + */ export const msalInstance = new PublicClientApplication(msalConfig); -root.render( - - - - - - - -); +/** + * Initialize MSAL before rendering the app. + * This is required for msal-browser v3+ to properly set up the library. + */ +msalInstance.initialize().then(() => { + // Do not render the full application inside MSAL's hidden iframe. + if (isInHiddenIframe) { + return; + } + + // Set the active account so acquireTokenSilent can resolve it + // automatically without every caller passing account explicitly. + if (!msalInstance.getActiveAccount()) { + const accounts = msalInstance.getAllAccounts(); + if (accounts.length > 0) { + msalInstance.setActiveAccount(accounts[0]); + } + } -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); + root.render( + + + + + + + + ); +}); diff --git a/ui/src/msal/authConfig.jsx b/ui/src/msal/authConfig.jsx index c2a4efde..958e8930 100644 --- a/ui/src/msal/authConfig.jsx +++ b/ui/src/msal/authConfig.jsx @@ -19,14 +19,9 @@ export const msalConfig = { }, cache: { cacheLocation: "localStorage", // This configures where your cache will be stored - storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge }, system: { allowRedirectInIframe: false, - preventCorsPreflight: true, - iframeHashTimeout: 10000, // Increase iframe timeout to 10 seconds - loadFrameTimeout: 10000, // Increase frame loading timeout - windowHashTimeout: 60000, // Increase overall timeout for redirect flows /** * Below you can configure MSAL.js logs. For more information, visit: * https://docs.microsoft.com/azure/active-directory/develop/msal-logging-js diff --git a/ui/src/msal/authHandler.jsx b/ui/src/msal/authHandler.jsx new file mode 100644 index 00000000..f5014ec3 --- /dev/null +++ b/ui/src/msal/authHandler.jsx @@ -0,0 +1,138 @@ +import React from "react"; + +import { useMsal } from "@azure/msal-react"; +import { EventType, InteractionStatus, InteractionRequiredAuthError } from "@azure/msal-browser"; + +import { loginRequest } from "./authConfig"; + +const INTERACTION_REQUIRED_ERROR_CODES = new Set([ + "interaction_required", + "login_required", + "consent_required", + "no_tokens_found", + "refresh_token_expired", + "monitor_window_timeout", + "timed_out", +]); + +function isInteractionRequiredError(error) { + if (!error) { + return false; + } + + if (error instanceof InteractionRequiredAuthError) { + return true; + } + + const errorCode = error.errorCode || error.subError; + + return typeof errorCode === "string" && INTERACTION_REQUIRED_ERROR_CODES.has(errorCode); +} + +function extractTokenRequest(event) { + const payload = event?.payload; + + if (payload && typeof payload === "object" && "request" in payload) { + return payload.request; + } + + return null; +} + +function AuthHandler() { + const { instance, inProgress } = useMsal(); + const [pendingInteraction, setPendingInteraction] = React.useState(null); + const activeRequestRef = React.useRef(null); + const redirectPendingRef = React.useRef(false); + + const resetInteraction = React.useCallback(() => { + activeRequestRef.current = null; + setPendingInteraction(null); + redirectPendingRef.current = false; + }, []); + + React.useEffect(() => { + const callbackId = instance.addEventCallback((event) => { + if ( + event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS || + event.eventType === EventType.LOGIN_SUCCESS + ) { + // Ensure the active account is set after a successful login so + // acquireTokenSilent can resolve it automatically. + if (event.eventType === EventType.LOGIN_SUCCESS && event.payload?.account) { + instance.setActiveAccount(event.payload.account); + } + + resetInteraction(); + return; + } + + if (event.eventType === EventType.ACQUIRE_TOKEN_FAILURE) { + const error = event.error; + const errorCode = event.errorCode; + + const shouldHandle = + isInteractionRequiredError(error) || + (typeof errorCode === "string" && INTERACTION_REQUIRED_ERROR_CODES.has(errorCode)); + + if (!shouldHandle) { + return; + } + + const tokenRequest = extractTokenRequest(event); + + const interaction = tokenRequest + ? { type: "token", tokenRequest, error } + : { type: "login", error }; + + activeRequestRef.current = interaction; + redirectPendingRef.current = false; + setPendingInteraction(interaction); + } + }); + + return () => { + if (callbackId) { + instance.removeEventCallback(callbackId); + } + }; + }, [instance, resetInteraction]); + + React.useEffect(() => { + if (!pendingInteraction) { + return; + } + + if (inProgress !== InteractionStatus.None || redirectPendingRef.current) { + return; + } + + const request = activeRequestRef.current; + + if (!request) { + return; + } + + redirectPendingRef.current = true; + + const invokeRedirect = async () => { + try { + if (request.type === "token" && request.tokenRequest) { + await instance.acquireTokenRedirect(request.tokenRequest); + } else { + await instance.loginRedirect(loginRequest); + } + } catch (error) { + console.error("Redirect request failed", error); + redirectPendingRef.current = false; + setPendingInteraction((current) => (current ? { ...current } : current)); + } + }; + + invokeRedirect(); + }, [pendingInteraction, inProgress, instance]); + + return null; +} + +export default AuthHandler; diff --git a/ui/src/msal/graph.jsx b/ui/src/msal/graph.jsx index b77da228..88f06b71 100644 --- a/ui/src/msal/graph.jsx +++ b/ui/src/msal/graph.jsx @@ -1,53 +1,20 @@ import axios from 'axios'; -import { InteractionRequiredAuthError, BrowserAuthError } from "@azure/msal-browser"; -import { msalInstance } from '../index'; +import { getGraphToken } from "./tokenService"; import { graphConfig } from "./authConfig"; -async function generateToken() { - const accounts = msalInstance.getAllAccounts(); - - if (accounts.length === 0) { - throw new Error("No user accounts found. Please login or re-authenticate first."); - } - - const request = { - scopes: ["User.Read", "Directory.Read.All"], - forceRefresh: true, - }; - - const tokenRequest = { - ...request, - account: accounts[0] - }; - - try { - const response = await msalInstance.acquireTokenSilent(tokenRequest); - return response.accessToken; - } catch (e) { - if (e instanceof InteractionRequiredAuthError || - (e instanceof BrowserAuthError && e.errorCode === "monitor_window_timeout")) { - - await msalInstance.acquireTokenRedirect(tokenRequest); - return null; - } else { - throw e; - } - } -} - const graph = axios.create(); graph.interceptors.request.use( async config => { - const token = await generateToken(); + const token = await getGraphToken(); config.headers['Authorization'] = `Bearer ${token}`; return config; }, error => { - Promise.reject(error) + return Promise.reject(error); }); export function callMsGraph() { @@ -56,23 +23,9 @@ export function callMsGraph() { return graph .get(url) .then(response => response.data) - .catch(async error => { + .catch(error => { console.log("ERROR CALLING MSGRAPH"); console.log(error); - - // If we get a 401, the token might be invalid - try to get a fresh token - if (error.response?.status === 401) { - console.log("401 error - attempting to refresh Graph token"); - try { - // Force a fresh token acquisition for Graph API - await generateToken(); - // The generateToken function will trigger a redirect if needed - } catch (tokenError) { - console.log("Token refresh failed:", tokenError); - throw tokenError; - } - } - throw error; }); } diff --git a/ui/src/msal/tokenService.js b/ui/src/msal/tokenService.js new file mode 100644 index 00000000..3f1d0306 --- /dev/null +++ b/ui/src/msal/tokenService.js @@ -0,0 +1,61 @@ +import { CacheLookupPolicy } from "@azure/msal-browser"; + +import { msalInstance } from "../index"; +import { apiRequest } from "./authConfig"; + +const GRAPH_SCOPES = ["User.Read", "Directory.Read.All"]; + +/** + * Resolve the current user account for token requests. + * + * Prefers the active account set via setActiveAccount(), but falls + * back to the first account in the cache. This handles the race + * condition where AuthenticatedTemplate renders (because an account + * exists in the cache after handleRedirectPromise resolves) before + * the LOGIN_SUCCESS event handler has called setActiveAccount(). + * + * If there are truly no accounts (first visit, not yet logged in), + * returns null — acquireTokenSilent will throw no_account_error and + * the Login component will redirect to AAD. + */ +function getAccount() { + return msalInstance.getActiveAccount() || msalInstance.getAllAccounts()[0] || null; +} + +/** + * Acquire an access token for the IPAM Engine API. + * + * Uses CacheLookupPolicy.AccessTokenAndRefreshToken so that + * acquireTokenSilent will try the cache and refresh token only — + * it will never fall back to a hidden iframe, avoiding the + * AADSTS160021 / timed_out errors that occur when the AAD + * browser session has expired. + * + * If the refresh token itself has expired, acquireTokenSilent + * will fail immediately and AuthHandler will trigger a single + * interactive redirect to re-authenticate the user. + */ +export async function getApiToken() { + const response = await msalInstance.acquireTokenSilent({ + ...apiRequest, + account: getAccount(), + cacheLookupPolicy: CacheLookupPolicy.AccessTokenAndRefreshToken, + }); + + return response.accessToken; +} + +/** + * Acquire an access token for the Microsoft Graph API. + * + * Same CacheLookupPolicy rationale as getApiToken above. + */ +export async function getGraphToken() { + const response = await msalInstance.acquireTokenSilent({ + scopes: GRAPH_SCOPES, + account: getAccount(), + cacheLookupPolicy: CacheLookupPolicy.AccessTokenAndRefreshToken, + }); + + return response.accessToken; +} diff --git a/ui/src/reportWebVitals.jsx b/ui/src/reportWebVitals.jsx deleted file mode 100644 index 5253d3ad..00000000 --- a/ui/src/reportWebVitals.jsx +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/ui/src/setupTests.jsx b/ui/src/setupTests.jsx deleted file mode 100644 index 74b1a275..00000000 --- a/ui/src/setupTests.jsx +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; diff --git a/ui/vite.config.js b/ui/vite.config.js index cef7cf8a..1d3a62ff 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -1,6 +1,5 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import eslint from "vite-plugin-eslint2"; export default () => { return defineConfig({ @@ -9,13 +8,6 @@ export default () => { // }, plugins: [ react(), - eslint({ - // cache: false, - lintOnStart: true, - lintInWorker: true, - include: ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"], - exclude: [], - }), { name: "build-ui-html", apply: "build",