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-assets.yml b/.github/workflows/azure-ipam-assets.yml index 19d2272e..6469be3b 100644 --- a/.github/workflows/azure-ipam-assets.yml +++ b/.github/workflows/azure-ipam-assets.yml @@ -19,19 +19,19 @@ jobs: - name: "Setup NodeJS v22" id: setupNode - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 - name: "Setup Python v3.11" id: setupPython - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install NPM Packages id: installNpmPackages @@ -51,7 +51,7 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Name ../assets/ -Force - ./build.ps1 -Path ../assets/ + ./build.ps1 -Path ../assets/ - name: Upload Azure IPAM Release Asset id: uploadReleaseAsset diff --git a/.github/workflows/azure-ipam-build.yml b/.github/workflows/azure-ipam-build.yml index e006ec68..1074d3a1 100644 --- a/.github/workflows/azure-ipam-build.yml +++ b/.github/workflows/azure-ipam-build.yml @@ -29,7 +29,7 @@ jobs: releaseTag: ${{ github.event.release.tag_name }} shell: pwsh run: | - $version = $env:releaseTag -replace "v", "" + $version = $env:releaseTag -replace "^v", "" Write-Output "ipamVersion=$version" >> $Env:GITHUB_OUTPUT build: @@ -40,7 +40,7 @@ jobs: steps: - name: Azure Login id: azureLogin - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -49,7 +49,7 @@ jobs: - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: sparse-checkout: | engine @@ -60,8 +60,8 @@ jobs: env: IPAM_VERSION: ${{ needs.init.outputs.ipamVersion }} run: | - az acr build -r $ACR_NAME -t ipam:$IPAM_VERSION -t ipam:latest -f ./Dockerfile.deb . - az acr build -r $ACR_NAME -t ipamfunc:$IPAM_VERSION -t ipamfunc:latest -f ./Dockerfile.func . + az acr build -r $ACR_NAME -t ipam:$IPAM_VERSION -t ipam:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./Dockerfile.deb . + az acr build -r $ACR_NAME -t ipamfunc:$IPAM_VERSION -t ipamfunc:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./Dockerfile.func . build-legacy: name: Build Legacy Azure IPAM Containers @@ -71,7 +71,7 @@ jobs: steps: - name: Azure Login id: azureLogin - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -80,7 +80,7 @@ jobs: - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: sparse-checkout: | engine @@ -92,7 +92,7 @@ jobs: env: IPAM_VERSION: ${{ needs.init.outputs.ipamVersion }} run: | - az acr build -r $ACR_NAME -t ipam-engine:$IPAM_VERSION -t ipam-engine:latest -f ./engine/Dockerfile.deb ./engine - az acr build -r $ACR_NAME -t ipam-func:$IPAM_VERSION -t ipam-func:latest -f ./engine/Dockerfile.func ./engine - az acr build -r $ACR_NAME -t ipam-ui:$IPAM_VERSION -t ipam-ui:latest -f ./ui/Dockerfile.deb ./ui - az acr build -r $ACR_NAME -t ipam-lb:$IPAM_VERSION -t ipam-lb:latest -f ./lb/Dockerfile ./lb + az acr build -r $ACR_NAME -t ipam-engine:$IPAM_VERSION -t ipam-engine:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./engine/Dockerfile.deb ./engine + az acr build -r $ACR_NAME -t ipam-func:$IPAM_VERSION -t ipam-func:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./engine/Dockerfile.func ./engine + az acr build -r $ACR_NAME -t ipam-ui:$IPAM_VERSION -t ipam-ui:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./ui/Dockerfile.deb ./ui + az acr build -r $ACR_NAME -t ipam-lb:$IPAM_VERSION -t ipam-lb:latest --build-arg IPAM_VERSION=$IPAM_VERSION -f ./lb/Dockerfile ./lb diff --git a/.github/workflows/azure-ipam-testing.yml b/.github/workflows/azure-ipam-testing.yml index 396283ad..0a9431ea 100644 --- a/.github/workflows/azure-ipam-testing.yml +++ b/.github/workflows/azure-ipam-testing.yml @@ -6,6 +6,21 @@ on: pull_request: branches: - main + paths-ignore: + - '.vscode/**' + - 'docs/**' + - 'examples/**' + - 'migrate/**' + - '.dockerignore' + - '.env.example' + - '.gitattributes' + - '.gitignore' + - 'LICENSE' + - '*.md' + +permissions: + id-token: write + contents: read env: ACR_NAME: ${{ vars.IPAM_TEST_ACR }} @@ -13,9 +28,87 @@ env: IPAM_ENGINE_NAME: ipam-engine-${{ github.run_id }}-${{ github.run_attempt }} jobs: + lint: + name: Lint & Build Checks + runs-on: ubuntu-latest + steps: + - name: Checkout Azure IPAM Code + id: checkoutCode + uses: actions/checkout@v6 + + - name: Setup Node.js + id: setupNode + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup Python + id: setupPython + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Lint Azure IPAM UI (ESLint) + id: lintUI + working-directory: ui + run: | + npm ci + npm run lint + + - name: Build Azure IPAM UI (Vite) + id: buildUI + working-directory: ui + run: npm run build + + - name: Lint Azure IPAM Engine (Ruff) + id: lintEngine + run: | + pip install ruff + ruff check engine/ + + - name: Lint Bicep Templates + id: lintBicep + run: az bicep build --file deploy/main.bicep --stdout > /dev/null + + - name: Lint Dockerfiles Debian (Hadolint) + id: lintDockerfilesDeb + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile.deb + recursive: true + + - name: Lint Dockerfiles RHEL (Hadolint) + id: lintDockerfilesRhel + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile.rhel + recursive: true + + - name: Lint Dockerfiles Function (Hadolint) + id: lintDockerfilesFunc + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile.func + recursive: true + + - name: Lint Dockerfiles Development (Hadolint) + id: lintDockerfilesDev + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile.dev + recursive: true + + - name: Lint Dockerfiles Load Balancer (Hadolint) + id: lintDockerfilesLb + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile + recursive: true + deploy: name: Deploy Azure IPAM runs-on: ubuntu-latest + needs: [ lint ] outputs: ipamURL: ${{ steps.deployScript.outputs.ipamURL }} ipamUIAppId: ${{ steps.deployScript.outputs.ipamUIAppId }} @@ -28,18 +121,21 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Az, Microsoft.Graph -AllowClobber -Force + Install-Module Az -AllowClobber -Force + Install-Module Microsoft.Graph -AllowClobber -Force - name: Azure Login id: azureLogin - uses: azure/login@v2 + uses: azure/login@v3 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_TEST_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TEST_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TEST_SUBSCRIPTION_ID }} enable-AzPSSession: true - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: sparse-checkout: | deploy @@ -49,7 +145,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 @@ -61,7 +157,7 @@ jobs: $bicepFile = Get-Content -Path ./modules/appService.bicep - $bicepFile = $bicepFile -replace "azureipam.azurecr.io", $acrName + $bicepFile = $bicepFile -replace "registry.azureipam.com", $acrName $bicepFile = $bicepFile -replace "ipam:latest", $containerName $bicepFile | Out-File -FilePath ./modules/appService.bicep -Force @@ -74,7 +170,8 @@ jobs: - name: "Upload Logs" id: uploadLogs - working-directory: logs + # Always upload logs (success or failure) so deployment failures can be reviewed + if: always() env: AZURE_IPAM_SUFFIX: ${{ steps.deployScript.outputs.ipamSuffix }} STORAGE_ACCT_RG: ${{ vars.LOGGING_STORAGE_RG }} @@ -82,9 +179,17 @@ jobs: STORAGE_ACCT_CTR: ${{ vars.LOGGING_STORAGE_CTR }} shell: pwsh run: | - $archiveName = "IPAM-RUN(${{ github.run_id }})-ATTEMPT(${{ github.run_attempt }})-SUFFIX($env:AZURE_IPAM_SUFFIX).zip" + $suffix = if ([string]::IsNullOrWhiteSpace($env:AZURE_IPAM_SUFFIX)) { "FAILED" } else { $env:AZURE_IPAM_SUFFIX } + $archiveName = "IPAM-RUN(${{ github.run_id }})-ATTEMPT(${{ github.run_attempt }})-SUFFIX($suffix).zip" + + $logDir = Join-Path $env:GITHUB_WORKSPACE "logs" - Compress-Archive -Path ./* -DestinationPath /tmp/$archiveName + if (-not (Test-Path -Path $logDir) -or -not (Get-ChildItem -Path $logDir -File -Recurse -ErrorAction SilentlyContinue)) { + Write-Warning "No log files found to upload; skipping log upload." + return + } + + Compress-Archive -Path (Join-Path $logDir '*') -DestinationPath /tmp/$archiveName $storage = Get-AzStorageAccount -Name $env:STORAGE_ACCT_NAME -ResourceGroupName $env:STORAGE_ACCT_RG $context = $storage.Context @@ -137,18 +242,21 @@ jobs: shell: pwsh run: | Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module Az, Pester -AllowClobber -Force + Install-Module Az -AllowClobber -Force + Install-Module Pester -AllowClobber -Force - name: Azure Login id: azureLogin - uses: azure/login@v2 + uses: azure/login@v3 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_TEST_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TEST_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TEST_SUBSCRIPTION_ID }} enable-AzPSSession: true - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: sparse-checkout: | tests @@ -193,19 +301,16 @@ jobs: - name: Azure Login id: azureLogin - uses: azure/login@v2 + uses: azure/login@v3 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_TEST_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TEST_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TEST_SUBSCRIPTION_ID }} enable-AzPSSession: true - - name : Remove Azure IPAM Resources - id: cleanupResources - shell: pwsh - run: | - Remove-AzResourceGroup -Name ${{ needs.deploy.outputs.ipamResourceGroup }} -Force - - name : Remove Azure IPAM Identities id: cleanupIdentities + if: always() shell: pwsh run: | $tenantId = (Get-AzContext).Tenant.Id @@ -215,13 +320,23 @@ jobs: $engineApp = Get-AzADApplication -ApplicationId ${{ needs.deploy.outputs.ipamEngineAppId }} $engineSpn = Get-AzADServicePrincipal -ApplicationId ${{ needs.deploy.outputs.ipamEngineAppId }} + Write-Host "Removing Engine SPN Role Assignment..." Remove-AzRoleAssignment -ObjectId $engineSpn.Id -Scope $scope -RoleDefinitionName Reader - $engineSpn | Remove-AzADServicePrincipal + Write-Host "Removing UI Application..." $uiApp | Remove-AzADApplication + Write-Host "Removing Engine Application..." $engineApp | Remove-AzADApplication - name: "Remove Azure IPAM Container" id: cleanupContainer + if: always() run: | az acr repository delete --name $ACR_NAME --repository ipam --yes + + - name : Remove Azure IPAM Resources + id: cleanupResources + if: always() + shell: pwsh + run: | + Remove-AzResourceGroup -Name ${{ needs.deploy.outputs.ipamResourceGroup }} -Force diff --git a/.github/workflows/azure-ipam-version.yml b/.github/workflows/azure-ipam-version.yml index 1da68505..f051e2fe 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 @@ -25,19 +37,19 @@ jobs: - name: "Setup NodeJS v22" id: setupNode - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 - name: "Setup Python v3.11" id: setupPython - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: "Extract Pull Request Details" id: getPullRequestData - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | return ( @@ -50,14 +62,14 @@ jobs: - name: "Create GitHub App Token" id: appToken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.IPAM_GITHUB_APP_ID }} private-key: ${{ secrets.IPAM_GITHUB_APP_KEY }} - name: Checkout Azure IPAM Code id: checkoutRepo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.appToken.outputs.token }} @@ -126,7 +138,7 @@ jobs: steps: - name: "Create GitHub App Token" id: appToken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.IPAM_GITHUB_APP_ID }} private-key: ${{ secrets.IPAM_GITHUB_APP_KEY }} 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/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 00000000..c29039fa --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,7 @@ +ignored: + - DL3002 + - DL3006 + - DL3008 + - DL3013 + - DL3041 + - DL3059 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..74300680 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,33 @@ 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 + +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} -# Disable PIP Root Warnings -ENV PIP_ROOT_USER_ACTION=ignore +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" # Set Working Directory WORKDIR /tmp +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + # 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 +83,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 +92,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 +113,4 @@ RUN chmod +x init.sh EXPOSE $PORT 2222 # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/Dockerfile.func b/Dockerfile.func index e1f86404..2eb41d16 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,12 +42,23 @@ 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 + +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # 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 +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Function" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" # Set Working Directory WORKDIR /tmp @@ -54,9 +67,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..b62b1546 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,11 +45,22 @@ 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 + +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} -# Disable PIP Root Warnings -ENV PIP_ROOT_USER_ACTION=ignore +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" # Set Working Directory WORKDIR /tmp @@ -55,14 +68,17 @@ 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 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] # 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 +86,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 +95,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 +121,4 @@ USER 1001 EXPOSE $PORT 2222 # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/README.md b/README.md index 1c70182b..053319e2 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,17 +6,17 @@ 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 | +| `update/` | Update Bicep Templates & PowerShell Update Script | | `lb/` | Load Balancer (NGINX) Configs | | `tests/` | Testing Scripts | -| `tools/` | Lifecycle Scripts (Build/Version/Update) | +| `tools/` | Lifecycle Scripts (Build/Version) | | `ui/` | UI Application Code | | `.dockerignore` | Untracked Docker Files to Ignore | | `.env.example` | Example ENV File to be Used with Docker Compose | @@ -61,7 +36,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 +45,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..29142b48 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 ` @@ -441,6 +463,44 @@ process { return $logs } + Function Invoke-WithGraphRetry { + Param( + [Parameter(Mandatory = $true)] + [scriptblock]$ScriptBlock, + [Parameter(Mandatory = $false)] + [int]$MaxAttempts = 8, + [Parameter(Mandatory = $false)] + [int]$BaseDelaySeconds = 2, + [Parameter(Mandatory = $false)] + [int]$MaxDelaySeconds = 30 + ) + + # Azure AD/Microsoft Graph is eventually consistent: a freshly created object may not yet be + # visible to the endpoint handling a follow-up request. The Az SDK retries transport faults but + # not these post-create reference errors, so we retry any thrown error here, backing off + # exponentially with jitter (capped at MaxDelaySeconds) to absorb propagation delay without + # stampeding the endpoint. Retries are silent; an exhausted retry re-throws the last error. + $attempt = 0 + + do { + $attempt++ + + try { + return & $ScriptBlock + } + catch { + if ($attempt -ge $MaxAttempts) { + throw $_ + } + + $backoff = [Math]::Min($BaseDelaySeconds * [Math]::Pow(2, $attempt - 1), $MaxDelaySeconds) + $jitter = Get-Random -Minimum 0.5 -Maximum 1.0 + + Start-Sleep -Seconds ([int][Math]::Ceiling($backoff * $jitter)) + } + } while ($attempt -lt $MaxAttempts) + } + Function Deploy-IPAMApplications { Param( [Parameter(Mandatory = $false)] @@ -575,15 +635,19 @@ process { Write-Host "INFO: Creating Azure IPAM Engine Application" -ForegroundColor Green # Create IPAM Engine Application - $engineApp = New-AzADApplication ` - -DisplayName $EngineAppName ` - -Api $engineApiSettings ` - -RequiredResourceAccess $engineResourceAccessList + $engineApp = Invoke-WithGraphRetry -ScriptBlock { + New-AzADApplication ` + -DisplayName $EngineAppName ` + -Api $engineApiSettings ` + -RequiredResourceAccess $engineResourceAccessList + } Write-Host "INFO: Updating Azure IPAM Engine API Endpoint" -ForegroundColor Green # Update IPAM Engine API Endpoint - Update-AzADApplication -ApplicationId $engineApp.AppId -IdentifierUri "api://$($engineApp.AppId)" + Invoke-WithGraphRetry -ScriptBlock { + Update-AzADApplication -ObjectId $engineApp.Id -IdentifierUri "api://$($engineApp.AppId)" + } $uiEngineApiAccess = @{ ResourceAppId = $engineApp.AppId @@ -601,18 +665,30 @@ process { if (-not $DisableUI) { Write-Host "INFO: Updating Azure IPAM UI Application Resource Access" -ForegroundColor Green - Update-AzADApplication -ApplicationId $uiApp.AppId -RequiredResourceAccess $uiResourceAccess + Invoke-WithGraphRetry -ScriptBlock { + Update-AzADApplication -ObjectId $uiApp.Id -RequiredResourceAccess $uiResourceAccess + } - $uiObject = Get-AzADApplication -ApplicationId $uiApp.AppId + $uiObject = Invoke-WithGraphRetry -ScriptBlock { + Get-AzADApplication -ObjectId $uiApp.Id + } } - $engineObject = Get-AzADApplication -ApplicationId $engineApp.AppId + $engineObject = Invoke-WithGraphRetry -ScriptBlock { + Get-AzADApplication -ObjectId $engineApp.Id + } # Create IPAM UI Service Principal (If DisableUI not specified) if (-not $DisableUI) { Write-Host "INFO: Creating Azure IPAM UI Service Principal" -ForegroundColor Green - New-AzADServicePrincipal -ApplicationObject $uiObject | Out-Null + Invoke-WithGraphRetry -ScriptBlock { + $uiSpnExists = [bool](Get-AzADServicePrincipal -ApplicationId $uiApp.AppId -ErrorAction SilentlyContinue) + + if (-not $uiSpnExists) { + New-AzADServicePrincipal -ApplicationObject $uiObject | Out-Null + } + } } $scope = "/providers/Microsoft.Management/managementGroups/$MgmtGroupId" @@ -620,15 +696,23 @@ process { Write-Host "INFO: Creating Azure IPAM Engine Service Principal" -ForegroundColor Green # Create IPAM Engine Service Principal - New-AzADServicePrincipal -ApplicationObject $engineObject ` - -Role "Reader" ` - -Scope $scope ` - | Out-Null + Invoke-WithGraphRetry -ScriptBlock { + $engineSpnExists = [bool](Get-AzADServicePrincipal -ApplicationId $engineApp.AppId -ErrorAction SilentlyContinue) + + if (-not $engineSpnExists) { + New-AzADServicePrincipal -ApplicationObject $engineObject ` + -Role "Reader" ` + -Scope $scope ` + | Out-Null + } + } Write-Host "INFO: Creating Azure IPAM Engine Secret" -ForegroundColor Green # Create IPAM Engine Secret - $engineSecret = New-AzADAppCredential -ApplicationObject $engineObject -StartDate (Get-Date) -EndDate (Get-Date).AddYears(2) + $engineSecret = Invoke-WithGraphRetry -ScriptBlock { + New-AzADAppCredential -ApplicationObject $engineObject -StartDate (Get-Date) -EndDate (Get-Date).AddYears(2) + } if (-not $DisableUI) { Write-Host "INFO: Azure IPAM Engine & UI Applications/Service Principals created successfully" -ForegroundColor Green @@ -700,19 +784,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 @@ -721,13 +793,23 @@ process { # Fetch Azure IPAM UI Service Principal (If DisableUI not specified) if (-not $DisableUI) { - $uiSpn = Get-AzADServicePrincipal ` - -ApplicationId $UIAppId + $uiSpn = Invoke-WithGraphRetry -ScriptBlock { + # Get-AzADServicePrincipal returns $null (without throwing) when the service principal + # has not yet replicated, so throw to trigger a retry instead of returning an empty result. + $spn = Get-AzADServicePrincipal -ApplicationId $UIAppId + if (-not $spn) { throw "IPAM UI Service Principal not yet available for App ID '$UIAppId'" } + return $spn + } } # Fetch Azure IPAM Engine Service Principal - $engineSpn = Get-AzADServicePrincipal ` - -ApplicationId $EngineAppId + $engineSpn = Invoke-WithGraphRetry -ScriptBlock { + # Get-AzADServicePrincipal returns $null (without throwing) when the service principal + # has not yet replicated, so throw to trigger a retry instead of returning an empty result. + $spn = Get-AzADServicePrincipal -ApplicationId $EngineAppId + if (-not $spn) { throw "IPAM Engine Service Principal not yet available for App ID '$EngineAppId'" } + return $spn + } # Grant admin consent for Microsoft Graph API permissions assigned to IPAM UI application (If DisableUI not specified) if (-not $DisableUI) { @@ -737,12 +819,14 @@ process { $msGraphId = Get-AzADServicePrincipal ` -ApplicationId $scope.scopeId - New-MgOauth2PermissionGrant ` - -ResourceId $msGraphId.Id ` - -Scope $scope.scopes ` - -ClientId $uiSpn.Id ` - -ConsentType AllPrincipals ` - | Out-Null + Invoke-WithGraphRetry -ScriptBlock { + New-MgOauth2PermissionGrant ` + -ResourceId $msGraphId.Id ` + -Scope $scope.scopes ` + -ClientId $uiSpn.Id ` + -ConsentType AllPrincipals ` + | Out-Null + } } Write-Host "INFO: Admin consent for Microsoft Graph API permissions granted successfully" -ForegroundColor Green @@ -752,12 +836,16 @@ process { if (-not $DisableUI) { Write-Host "INFO: Granting admin consent to the IPAM UI application for exposed API from the IPAM Engine application" -ForegroundColor Green - New-MgOauth2PermissionGrant ` - -ResourceId $engineSpn.Id ` - -Scope "access_as_user" ` - -ClientId $uiSpn.Id ` - -ConsentType AllPrincipals ` - | Out-Null + # Retry to absorb replication lag: the engine service principal was just created and may not yet + # be visible to the Microsoft Graph endpoint processing the grant ("resourceId not found"). + Invoke-WithGraphRetry -ScriptBlock { + New-MgOauth2PermissionGrant ` + -ResourceId $engineSpn.Id ` + -Scope "access_as_user" ` + -ClientId $uiSpn.Id ` + -ConsentType AllPrincipals ` + | Out-Null + } Write-Host "INFO: Admin consent for IPAM Engine exposed API granted successfully" -ForegroundColor Green } @@ -769,12 +857,16 @@ process { $msGraphId = Get-AzADServicePrincipal ` -ApplicationId $scope.scopeId - New-MgOauth2PermissionGrant ` - -ResourceId $msGraphId.Id ` - -Scope $scope.scopes ` - -ClientId $engineSpn.Id ` - -ConsentType AllPrincipals ` - | Out-Null + # Retry to absorb replication lag: the engine service principal was just created and may not yet + # be visible to the Microsoft Graph endpoint processing the grant ("resourceId not found"). + Invoke-WithGraphRetry -ScriptBlock { + New-MgOauth2PermissionGrant ` + -ResourceId $msGraphId.Id ` + -Scope $scope.scopes ` + -ClientId $engineSpn.Id ` + -ConsentType AllPrincipals ` + | Out-Null + } } Write-Host "INFO: Admin consent for Azure Service Management API permissions granted successfully" -ForegroundColor Green @@ -996,11 +1088,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 @@ -1251,8 +1339,8 @@ process { Extension = 'rhel' Port = 8080 Images = @{ - Build = 'registry.access.redhat.com/ubi8/nodejs-22' - Serve = 'registry.access.redhat.com/ubi8/python-311' + Build = 'registry.access.redhat.com/ubi9/nodejs-22' + Serve = 'registry.access.redhat.com/ubi9/python-311' } } } @@ -1399,7 +1487,5 @@ process { Write-Output "ipamSuffix=$($deployment.Outputs["suffix"].Value)" >> $env:GITHUB_OUTPUT Write-Output "ipamResourceGroup=$($deployment.Outputs["resourceGroupName"].Value)" >> $env:GITHUB_OUTPUT } - - exit } } 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/appService.bicep b/deploy/modules/appService.bicep index ed245169..28355787 100644 --- a/deploy/modules/appService.bicep +++ b/deploy/modules/appService.bicep @@ -41,7 +41,7 @@ param privateAcr bool param privateAcrUri string // ACR Uri Variable -var acrUri = privateAcr ? privateAcrUri : 'azureipam.azurecr.io' +var acrUri = privateAcr ? privateAcrUri : 'registry.azureipam.com' // Disable Build Process Internet-Restricted Clouds var runFromPackage = azureCloud == 'AZURE_US_GOV_SECRET' ? true : false @@ -50,6 +50,84 @@ var runFromPackage = azureCloud == 'AZURE_US_GOV_SECRET' ? true : false var engineVersion = loadJsonContent('../../engine/app/version.json') var pythonVersion = engineVersion.python +// Shared App Service site configuration (reused by the production site and the staging slot) +var appServiceSiteConfig = { + acrUseManagedIdentityCreds: privateAcr ? true : false + acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null + alwaysOn: true + linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipam:latest' : 'PYTHON|${pythonVersion}' + appCommandLine: !deployAsContainer ? 'bash ./init.sh 8000' : null + healthCheckPath: '/api/status' + appSettings: concat( + [ + { + name: 'AZURE_ENV' + value: azureCloud + } + { + name: 'COSMOS_URL' + value: cosmosDbUri + } + { + name: 'DATABASE_NAME' + value: databaseName + } + { + name: 'CONTAINER_NAME' + value: containerName + } + { + name: 'MANAGED_IDENTITY_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + } + { + name: 'UI_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + } + { + name: 'ENGINE_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + } + { + name: 'ENGINE_APP_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + } + { + name: 'TENANT_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + } + { + name: 'KEYVAULT_URL' + value: keyVaultUri + } + { + name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' + value: '2' + } + ], + deployAsContainer ? [ + { + name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' + value: 'true' + } + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + ] : runFromPackage ? [ + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + ] : [ + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' + } + ] + ) +} + resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { name: appServicePlanName location: location @@ -79,86 +157,32 @@ resource appService 'Microsoft.Web/sites@2021-02-01' = { httpsOnly: true serverFarmId: appServicePlan.id keyVaultReferenceIdentity: managedIdentityId - siteConfig: { - acrUseManagedIdentityCreds: privateAcr ? true : false - acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null - alwaysOn: true - linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipam:latest' : 'PYTHON|${pythonVersion}' - appCommandLine: !deployAsContainer ? 'bash ./init.sh 8000' : null - healthCheckPath: '/api/status' - appSettings: concat( - [ - { - name: 'AZURE_ENV' - value: azureCloud - } - { - name: 'COSMOS_URL' - value: cosmosDbUri - } - { - name: 'DATABASE_NAME' - value: databaseName - } - { - name: 'CONTAINER_NAME' - value: containerName - } - { - name: 'MANAGED_IDENTITY_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' - } - { - name: 'UI_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' - } - { - name: 'ENGINE_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' - } - { - name: 'ENGINE_APP_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' - } - { - name: 'TENANT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' - } - { - name: 'KEYVAULT_URL' - value: keyVaultUri - } - { - name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' - value: '2' - } - ], - deployAsContainer ? [ - { - name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' - value: 'true' - } - { - name: 'DOCKER_REGISTRY_SERVER_URL' - value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' - } - ] : runFromPackage ? [ - { - name: 'WEBSITE_RUN_FROM_PACKAGE' - value: '1' - } - ] : [ - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'true' - } - ] - ) + siteConfig: appServiceSiteConfig + } +} + +// Staging slot (created dormant; started on demand for slot-based upgrades/swaps) +resource appServiceStagingSlot 'Microsoft.Web/sites/slots@2021-02-01' = { + name: 'staging' + parent: appService + location: location + kind: deployAsContainer ? 'app,linux,container' : 'app,linux' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} } } + properties: { + enabled: false + httpsOnly: true + serverFarmId: appServicePlan.id + keyVaultReferenceIdentity: managedIdentityId + siteConfig: appServiceSiteConfig + } } -resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { +resource appServiceLogs 'Microsoft.Web/sites/config@2021-02-01' = { name: 'logs' parent: appService properties: { @@ -178,6 +202,26 @@ resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { } } +resource appServiceStagingSlotLogs 'Microsoft.Web/sites/slots/config@2021-02-01' = { + name: 'logs' + parent: appServiceStagingSlot + properties: { + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 50 + } + } + } +} + resource diagnosticSettingsPlan 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { name: 'diagSettings' scope: appServicePlan @@ -281,3 +325,4 @@ resource diagnosticSettingsApp 'Microsoft.Insights/diagnosticSettings@2021-05-01 } output appServiceHostName string = appService.properties.defaultHostName +output appServiceStagingSlotHostName string = appServiceStagingSlot.properties.defaultHostName diff --git a/deploy/modules/functionApp.bicep b/deploy/modules/functionApp.bicep index 05650e93..49be84f9 100644 --- a/deploy/modules/functionApp.bicep +++ b/deploy/modules/functionApp.bicep @@ -44,7 +44,7 @@ param privateAcr bool param privateAcrUri string // ACR Uri Variable -var acrUri = privateAcr ? privateAcrUri : 'azureipam.azurecr.io' +var acrUri = privateAcr ? privateAcrUri : 'registry.azureipam.com' // Disable Build Process Internet-Restricted Clouds var runFromPackage = azureCloud == 'AZURE_US_GOV_SECRET' ? true : false @@ -53,6 +53,116 @@ var runFromPackage = azureCloud == 'AZURE_US_GOV_SECRET' ? true : false var engineVersion = loadJsonContent('../../engine/app/version.json') var pythonVersion = engineVersion.python +// Shared Function App site configuration (reused by the production site and the staging slot) +var functionAppSiteConfig = { + acrUseManagedIdentityCreds: privateAcr ? true : false + acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null + linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipamfunc:latest' : 'PYTHON|${pythonVersion}' + healthCheckPath: '/api/status' + appSettings: concat( + [ + { + name: 'AZURE_ENV' + value: azureCloud + } + { + name: 'COSMOS_URL' + value: cosmosDbUri + } + { + name: 'DATABASE_NAME' + value: databaseName + } + { + name: 'CONTAINER_NAME' + value: containerName + } + { + name: 'MANAGED_IDENTITY_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + } + { + name: 'UI_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + } + { + name: 'ENGINE_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + } + { + name: 'ENGINE_APP_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + } + { + name: 'TENANT_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + } + { + name: 'KEYVAULT_URL' + value: keyVaultUri + } + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { + name: 'WEBSITE_CONTENTSHARE' + value: toLower(functionAppName) + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: applicationInsights.properties.InstrumentationKey + } + { + name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' + value: '2' + } + ], + deployAsContainer ? [ + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + { + name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' + value: 'false' + } + ] : runFromPackage ? [ + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'python' + } + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + ] : [ + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'python' + } + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'true' + } + ] + ) +} + +// Settings that must remain with their slot during a swap (not swapped) +var functionAppStickySettingNames = [ + 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + 'WEBSITE_CONTENTSHARE' +] + resource storageAccount 'Microsoft.Storage/storageAccounts@2021-06-01' existing = { name: storageAccountName } @@ -84,112 +194,41 @@ resource functionApp 'Microsoft.Web/sites@2021-03-01' = { httpsOnly: true serverFarmId: functionPlan.id keyVaultReferenceIdentity: managedIdentityId - siteConfig: { - acrUseManagedIdentityCreds: privateAcr ? true : false - acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null - linuxFxVersion: deployAsContainer ? 'DOCKER|${acrUri}/ipamfunc:latest' : 'PYTHON|${pythonVersion}' - healthCheckPath: '/api/status' - appSettings: concat( - [ - { - name: 'AZURE_ENV' - value: azureCloud - } - { - name: 'COSMOS_URL' - value: cosmosDbUri - } - { - name: 'DATABASE_NAME' - value: databaseName - } - { - name: 'CONTAINER_NAME' - value: containerName - } - { - name: 'MANAGED_IDENTITY_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' - } - { - name: 'UI_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' - } - { - name: 'ENGINE_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' - } - { - name: 'ENGINE_APP_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' - } - { - name: 'TENANT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' - } - { - name: 'KEYVAULT_URL' - value: keyVaultUri - } - { - name: 'AzureWebJobsStorage' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - { - name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - { - name: 'WEBSITE_CONTENTSHARE' - value: toLower(functionAppName) - } - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'APPINSIGHTS_INSTRUMENTATIONKEY' - value: applicationInsights.properties.InstrumentationKey - } - { - name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' - value: '2' - } - ], - deployAsContainer ? [ - { - name: 'DOCKER_REGISTRY_SERVER_URL' - value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' - } - { - name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE' - value: 'false' - } - ] : runFromPackage ? [ - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'python' - } - { - name: 'WEBSITE_RUN_FROM_PACKAGE' - value: '1' - } - ] : [ - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'python' - } - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'true' - } - ] - ) + siteConfig: functionAppSiteConfig + } +} + +// Staging slot (created dormant; started on demand for slot-based upgrades/swaps) +resource functionAppStagingSlot 'Microsoft.Web/sites/slots@2021-03-01' = { + name: 'staging' + parent: functionApp + location: location + kind: 'functionapp,linux' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} } } + properties: { + enabled: false + httpsOnly: true + serverFarmId: functionPlan.id + keyVaultReferenceIdentity: managedIdentityId + siteConfig: functionAppSiteConfig + } } -resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { +// Mark content-share settings as slot-sticky so a future swap never crosses the file share +resource functionAppSlotConfigNames 'Microsoft.Web/sites/config@2021-03-01' = { + name: 'slotConfigNames' + parent: functionApp + properties: { + appSettingNames: functionAppStickySettingNames + } +} + +resource functionAppLogs 'Microsoft.Web/sites/config@2021-02-01' = { name: 'logs' parent: functionApp properties: { @@ -209,6 +248,26 @@ resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { } } +resource functionAppStagingSlotLogs 'Microsoft.Web/sites/slots/config@2021-02-01' = { + name: 'logs' + parent: functionAppStagingSlot + properties: { + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 50 + } + } + } +} + resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { name: functionAppName location: location @@ -248,7 +307,7 @@ resource diagnosticSettingsApp 'Microsoft.Insights/diagnosticSettings@2021-05-01 enabled: true retentionPolicy: { days: 0 - enabled: false + enabled: false } } ] @@ -267,3 +326,4 @@ resource diagnosticSettingsApp 'Microsoft.Insights/diagnosticSettings@2021-05-01 } output functionAppHostName string = functionApp.properties.defaultHostName +output functionAppStagingSlotHostName string = functionAppStagingSlot.properties.defaultHostName 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 deleted file mode 100644 index b2ebd46f..00000000 --- a/deploy/update.ps1 +++ /dev/null @@ -1,645 +0,0 @@ -############################################################################################################### -## -## Azure IPAM ZIP Deploy Updater Script -## -############################################################################################################### - -# Set minimum version requirements -#Requires -Version 7.2 -#Requires -Modules @{ ModuleName="Az.Accounts"; ModuleVersion="2.13.0" } -#Requires -Modules @{ ModuleName="Az.Functions"; ModuleVersion="4.0.6" } -#Requires -Modules @{ ModuleName="Az.Websites"; ModuleVersion="3.1.1" } - -# Intake and set global parameters -param( - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory = $true)] - [string] - $AppName, - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory = $true)] - [string] - $ResourceGroupName, - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory=$false)] - [string] - $GitHubUserName = "Azure", - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory=$false)] - [string] - $GitHubRepoName = "ipam", - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory=$false)] - [ValidateScript({ - $IndexOfInvalidChar = $_.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) - if (-Not ($IndexOfInvalidChar -eq -1)) { - throw [System.ArgumentException]::New("The 'ZipFileName' argument contains one or more invalid characters.") - } - if(-Not ($_ -match "(\.zip)")) { - throw [System.ArgumentException]::New("The 'ZipFileName' argument must be of type zip.") - } - return $true - })] - [string] - $ZipFileName = "ipam.zip", - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory=$false)] - [ValidateScript({ - if(-Not ($_ | Get-Item) ) { - throw [System.ArgumentException]::New("AssetFolder does not exist, please provide a pre-existing folder.") - } - return $true - })] - [System.IO.DirectoryInfo] - $AssetFolder, - - [Parameter(ValueFromPipelineByPropertyName = $true, - Mandatory = $false)] - [ValidateScript({ - if(-Not ($_ | Test-Path) ) { - throw [System.ArgumentException]::New("Target file or does not exist.") - } - if(-Not ($_ | Test-Path -PathType Leaf) ) { - throw [System.ArgumentException]::New("The 'ZipFilePath' argument must be a file, folder paths are not allowed.") - } - if($_ -notmatch "(\.zip)") { - throw [System.ArgumentException]::New("The file specified in the 'ZipFilePath' argument must be of type zip.") - } - return $true - })] - [System.IO.FileInfo] - $ZipFilePath -) - -# Root Directory -$ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName - -# Minimum Required Azure CLI Version -$MIN_AZ_CLI_VER = [System.Version]'2.35.0' - -# Azure IPAM Public ACR -$IPAM_PUBLIC_ACR = "azureipam.azurecr.io" - -# Set preference variables -$ErrorActionPreference = "Stop" - -# Hide Azure PowerShell SDK Warnings -$Env:SuppressAzurePowerShellBreakingChangeWarnings = $true - -# Hide Azure PowerShell SDK & Azure CLI Survey Prompts -$Env:AzSurveyMessage = $false -$Env:AZURE_CORE_SURVEY_MESSAGE = $false - -# Set Log File Location -$logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" -New-Item -ItemType Directory -Path $logpath -Force | Out-Null - -$updateLog = Join-Path -Path $logPath -ChildPath "update_$(get-date -format `"yyyyMMddhhmmsstt`").log" -$errorLog = Join-Path -Path $logPath -ChildPath "error_$(get-date -format `"yyyyMMddhhmmsstt`").log" - -$containerBuildError = $false - -$TempFolderObj = $null - -Function Get-BuildLogs { - Param( - [Parameter(Mandatory=$true)] - [string]$SubscriptionId, - [Parameter(Mandatory=$true)] - [string]$ResourceGroupName, - [Parameter(Mandatory=$true)] - [string]$RegistryName, - [Parameter(Mandatory=$true)] - [string]$BuildId - ) - - $msArmMap = @{ - AZURE_PUBLIC = "management.azure.com" - AZURE_US_GOV = "management.usgovcloudapi.net" - AZURE_US_GOV_SECRET = "management.azure.microsoft.scloud" - AZURE_GERMANY = "management.microsoftazure.de" - AZURE_CHINA = "management.chinacloudapi.cn" - }; - - $accessToken = (Get-AzAccessToken).Token | ConvertTo-SecureString -AsPlainText - - $response = Invoke-RestMethod ` - -Method POST ` - -Uri "https://$($msArmMap[$AzureCloud])/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.ContainerRegistry/registries/$RegistryName/runs/$BuildId/listLogSasUrl?api-version=2019-04-01" ` - -Authentication Bearer ` - -Token $accessToken - - $logLink = $response.logLink - - $logs = Invoke-RestMethod ` - -Method GET ` - -Uri $logLink - - return $logs -} - -Function Set-HealthCheck { - Param( - [Parameter(Mandatory=$true)] - [string]$AppName, - [Parameter(Mandatory=$true)] - [string]$ResourceGroupName - ) - - Set-AzResource ` - -ResourceGroupName $ResourceGroupName ` - -ResourceType "Microsoft.Web/sites/config" ` - -ResourceName "${AppName}/web" ` - -ApiVersion "2023-12-01" ` - -Properties @{ healthCheckPath = "/api/status" } ` - -Force ` - | Out-Null - - $existing = @{} - - $site = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $AppName - $site.SiteConfig.AppSettings | ForEach-Object { $existing[$_.Name] = $_.Value } - $existing["WEBSITE_HEALTHCHECK_MAXPINGFAILURES"] = "2" - - Set-AzWebApp ` - -ResourceGroupName $ResourceGroupName ` - -Name $AppName ` - -AppSettings $existing ` - | Out-Null -} - -Function Restart-IpamApp { - Param( - [Parameter(Mandatory=$true)] - [string]$AppName, - [Parameter(Mandatory=$true)] - [string]$ResourceGroupName, - [Parameter(Mandatory=$false)] - [switch]$Function - ) - - $restartRetries = 5 - $restartSuccess = $False - - do { - try { - if ($Function) { - Restart-AzFunctionApp ` - -Name $AppName ` - -ResourceGroupName $ResourceGroupName ` - -ErrorVariable restartErr ` - -ErrorAction SilentlyContinue ` - -Force ` - | Out-Null - } else { - Restart-AzWebApp ` - -Name $AppName ` - -ResourceGroupName $ResourceGroupName ` - -ErrorVariable restartErr ` - -ErrorAction SilentlyContinue ` - | Out-Null - } - - if ($restartErr) { - throw $restartErr - } - - $restartSuccess = $True - Write-Host "INFO: Application successfuly restarted" -ForegroundColor Green - } catch { - if($restartRetries -gt 0) { - Write-Host "WARNING: Problem while restarting application! Retrying..." -ForegroundColor Yellow - $restartRetries-- - } else { - Write-Host "ERROR: Unable to restart application!" -ForegroundColor Red - throw $_ - } - } - } while ($restartSuccess -eq $False -and $restartRetries -gt 0) -} - -Function Get-ZipFile { - Param( - [Parameter(Mandatory=$true)] - [string]$GitHubUserName, - [Parameter(Mandatory=$true)] - [string]$GitHubRepoName, - [Parameter(Mandatory=$true)] - [string]$ZipFileName, - [Parameter(Mandatory=$true)] - [System.IO.DirectoryInfo]$AssetFolder - ) - - $ZipFilePath = Join-Path -Path $AssetFolder.FullName -ChildPath $ZipFileName - - try { - $GitHubURL = "https://api.github.com/repos/$GitHubUserName/$GitHubRepoName/releases/latest" - - Write-Host "INFO: Target GitHub Repo is " -ForegroundColor Green -NoNewline - Write-Host "$GitHubUserName/$GitHubRepoName" -ForegroundColor Cyan - Write-Host "INFO: Fetching download URL..." -ForegroundColor Green - - $GHResponse = Invoke-WebRequest -Method GET -Uri $GitHubURL - $JSONResponse = $GHResponse.Content | ConvertFrom-Json - $AssetList = $JSONResponse.assets - $Asset = $AssetList | Where-Object { $_.name -eq $ZipFileName } - $DownloadURL = $Asset.browser_download_url - - Write-Host "INFO: Downloading ZIP Archive to " -ForegroundColor Green -NoNewline - Write-Host $ZipFilePath -ForegroundColor Cyan - - Invoke-WebRequest -Uri $DownloadURL -OutFile $ZipFilePath - } catch { - Write-Host "ERROR: Unable to download ZIP Deploy archive!" -ForegroundColor Red - throw $_ - } -} - -Function Publish-ZipFile { - Param( - [Parameter(Mandatory=$true)] - [string]$AppName, - [Parameter(Mandatory=$true)] - [string]$ResourceGroupName, - [Parameter(Mandatory=$true)] - [System.IO.FileInfo]$ZipFilePath, - [Parameter(Mandatory=$false)] - [switch]$UseAPI - ) - - if ($UseAPI) { - Write-Host "INFO: Using Kudu API for ZIP Deploy" -ForegroundColor Green - } - - $publishRetries = 3 - $publishSuccess = $False - - if ($UseAPI) { - $accessToken = (Get-AzAccessToken).Token - $zipContents = Get-Item -Path $ZipFilePath - - $publishProfile = Get-AzWebAppPublishingProfile -Name $AppName -ResourceGroupName $ResourceGroupName - $zipUrl = ([System.uri]($publishProfile | Select-Xml -XPath "//publishProfile[@publishMethod='ZipDeploy']" | Select-Object -ExpandProperty Node).publishUrl).Scheme - } - - do { - try { - if (-not $UseAPI) { - Publish-AzWebApp ` - -Name $AppName ` - -ResourceGroupName $ResourceGroupName ` - -ArchivePath $ZipFilePath ` - -Restart ` - -Force ` - | Out-Null - } else { - Invoke-RestMethod ` - -Uri "https://${zipUrl}/api/zipdeploy" ` - -Method Post ` - -ContentType "multipart/form-data" ` - -Headers @{ "Authorization" = "Bearer $accessToken" } ` - -Form @{ file = $zipContents } ` - -StatusCodeVariable statusCode ` - | Out-Null - - if ($statusCode -ne 200) { - throw [System.Exception]::New("Error while uploading ZIP Deploy via Kudu API! ($statusCode)") - } - } - - $publishSuccess = $True - Write-Host "INFO: ZIP Deploy archive successfully uploaded" -ForegroundColor Green - } catch { - if($publishRetries -gt 0) { - Write-Host "WARNING: Problem while uploading ZIP Deploy archive! Retrying..." -ForegroundColor Yellow - $publishRetries-- - } else { - Write-Host "ERROR: Unable to upload ZIP Deploy archive!" -ForegroundColor Red - throw $_ - } - } - } while ($publishSuccess -eq $False -and $publishRetries -ge 0) -} - -Start-Transcript -Path $updateLog | Out-Null - -try { - Write-Host - Write-Host "INFO: Verifying application exists..." -ForegroundColor Green - - $appType = "" - $isFunction = $false - $privateAcr = $false - $acrName = "" - - $existingApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $AppName -ErrorAction SilentlyContinue - - if($null -eq $existingApp) { - Write-Host "ERROR: Application not found in current subscription!" -ForegroundColor Red - throw "Application does not exist!" - } else { - $appKind = $existingApp.Kind - $appType = $($appKind.Split(",") -contains 'functionapp') ? 'Function' : 'App' - $isFunction = $appType -eq 'Function' ? $true : $false - } - - $isContainer = $appKind.Split(",") -contains 'container' - - if ($isContainer) { - $appType += "Container" - } - - Write-Host "INFO: Application exists, detected type is " -ForegroundColor Green -NoNewline - Write-Host $appType -ForegroundColor Cyan - - if($isContainer -and $existingApp.SiteConfig.LinuxFxVersion.StartsWith("COMPOSE|")) { - Write-Host "WARNING: Legacy Docker Compose detected!" -ForegroundColor Yellow - Write-Host - Write-Host "Please follow the migration guide at " -ForegroundColor Blue -NoNewline - Write-Host "https://azure.github.io/ipam/#/migration/README" -ForegroundColor Cyan -NoNewline - Write-Host " for complete instructions." -ForegroundColor Blue - exit - } - - if ($null -eq $existingApp.SiteConfig.HealthCheckPath) { - Write-Host "WARNING: Health Check is missing!" -ForegroundColor Yellow - Write-Host "INFO: Adding application health check..." -ForegroundColor Green - - Set-HealthCheck -ResourceGroupName $ResourceGroupName -AppName $AppName - } - - if ($isContainer) { - $appAcr = $existingApp.SiteConfig.LinuxFxVersion.Split('|')[1].Split('/')[0] - $privateAcr = $appAcr -eq $IPAM_PUBLIC_ACR ? $false : $true - - if (-not $privateAcr) { - Write-Host "INFO: Deployment is using the Azure IPAM public ACR, restarting to update..." -ForegroundColor Green - Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName - exit - } - - if($privateAcr) { - $acrName = $appAcr.Split('.')[0] - - Write-Host "INFO: Deployment is using a private ACR (" -ForegroundColor Green -NoNewline - Write-Host "$acrName" -ForegroundColor Cyan -NoNewline - Write-Host ")" -ForegroundColor Green - Write-Host "INFO: Verifying ACR is in current Resource Group" -ForegroundColor Green - - $acrDetails = Get-AzContainerRegistry ` - -Name $acrName ` - -ResourceGroupName $ResourceGroupName ` - -ErrorVariable acrErr ` - -ErrorAction SilentlyContinue - - if ($acrErr) { - Write-Host "ERROR: Private ACR not found in current Resource Group!" -ForegroundColor Red - throw $acrErr - } - - $acrName = $acrDetails.Name - - Write-Host "INFO: Verifying minimum Azure CLI version" -ForegroundColor Green - - # Verify Minimum Azure CLI Version - $azureCliVer = [System.Version](az version | ConvertFrom-Json).'azure-cli' - - if($azureCliVer -lt $MIN_AZ_CLI_VER) { - Write-Host "ERROR: Azure CLI must be version $MIN_AZ_CLI_VER or greater!" -ForegroundColor Red - exit - } - - Write-Host "INFO: Verifying Azure PowerShell and Azure CLI contexts match" -ForegroundColor Green - - # Verify Azure PowerShell and Azure CLI Contexts Match - $azureCliContext = $(az account show | ConvertFrom-Json) 2>$null - - if(-not $azureCliContext) { - Write-Host "ERROR: Azure CLI not logged in or no subscription has been selected!" -ForegroundColor Red - exit - } - - $azureCliSub = $azureCliContext.id - $azurePowerShellSub = (Get-AzContext).Subscription.Id - - if($azurePowerShellSub -ne $azureCliSub) { - Write-Host "ERROR: Azure PowerShell and Azure CLI must be set to the same context!" -ForegroundColor Red - exit - } - } - } - - if (-not $isContainer) { - Write-Host "INFO: Verifying application Python version" -ForegroundColor Green - - $engineFolder = Join-Path -Path $ROOT_DIR -ChildPath 'engine' - $engineVersionFile = Join-Path -Path $engineFolder -ChildPath "app" -AdditionalChildPath 'version.json' - $enginePythonVersion = $(Get-Content -Path $engineVersionFile | ConvertFrom-Json).python - - $appPythonVersion = $existingApp.SiteConfig.LinuxFxVersion.Split('|')[1] - - if($enginePythonVersion -ne $appPythonVersion) { - Write-Host "WARNING: Python version has changed (" -ForegroundColor Yellow -NoNewline - Write-Host "v$appPythonVersion -> v$enginePythonVersion" -ForegroundColor Cyan -NoNewline - Write-Host ")" -ForegroundColor Yellow - Write-Host "INFO: Updating application Python version..." -ForegroundColor Green - - $existingApp.SiteConfig.LinuxFxVersion = "PYTHON|$enginePythonVersion" - $existingApp | Set-AzWebApp | Out-Null - - Start-Sleep -Seconds 10 - } - } - - if ($isContainer) { - if (-not $isFunction) { - Write-Host "INFO: Detecting container distro..." -ForegroundColor Green - - $appUri = $existingApp.HostNames[0] - $statusUri = "https://${appUri}/api/status" - - try { - $status = Invoke-RestMethod -Method Get -Uri $statusUri -ErrorVariable statusErr -ErrorAction SilentlyContinue - } catch { - Write-Host "ERROR: Unable to detect container distro!" -ForegroundColor Red - throw $_ - } - - $containerType = $status.container.image_id - } - - Write-Host "INFO: Building and pushing container images to Azure Container Registry" -ForegroundColor Green - - $containerMap = @{ - debian = @{ - Extension = 'deb' - Port = 80 - Images = @{ - Build = 'node:22-slim' - Serve = 'python:3.11-slim' - } - } - rhel = @{ - Extension = 'rhel' - Port = 8080 - Images = @{ - Build = 'registry.access.redhat.com/ubi8/nodejs-22' - Serve = 'registry.access.redhat.com/ubi8/python-311' - } - } - } - - if($containerType) { - $dockerFile = 'Dockerfile.' + $containerMap[$containerType].Extension - $dockerFilePath = Join-Path -Path $ROOT_DIR -ChildPath $dockerFile - } - - $dockerFileFunc = Join-Path -Path $ROOT_DIR -ChildPath 'Dockerfile.func' - - if($isFunction) { - Write-Host "INFO: Building Function container..." -ForegroundColor Green - - $funcBuildOutput = $( - az acr build -r $acrName ` - -t ipamfunc:latest ` - -f $dockerFileFunc $ROOT_DIR ` - --no-logs - ) *>&1 - - if ($LASTEXITCODE -ne 0) { - Write-Host "ERROR: Container build process failed, fetching error logs..." -ForegroundColor Red - - $buildId = [regex]::Matches($funcBuildOutput, "(?<=Queued a build with ID: )[\w]*").Value.Trim() - - $buildLogs = Get-BuildLogs ` - -SubscriptionId (Get-AzContext).Subscription.Id ` - -ResourceGroupName $ResourceGroupName ` - -RegistryName $acrName ` - -BuildId $buildId - - $buildLogs | Out-File -FilePath $errorLog -Append - - $script:containerBuildError = $true - } else { - Write-Host "INFO: Function container image build and push completed successfully" -ForegroundColor Green - } - - Write-Host "INFO: Restarting Function App" -ForegroundColor Green - - Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName -Function - } else { - Write-Host "INFO: Building App container (" -ForegroundColor Green -NoNewline - Write-Host "$containerType" -ForegroundColor Cyan -NoNewline - Write-Host ")..." -ForegroundColor Green - - $appBuildOutput = $( - az acr build -r $acrName ` - -t ipam:latest ` - -f $dockerFilePath $ROOT_DIR ` - --build-arg PORT=$($containerMap[$ContainerType].Port) ` - --build-arg BUILD_IMAGE=$($containerMap[$containerType].Images.Build) ` - --build-arg SERVE_IMAGE=$($containerMap[$containerType].Images.Serve) ` - --no-logs - ) *>&1 - - if ($LASTEXITCODE -ne 0) { - Write-Host "ERROR: Container build process failed, fetching error logs..." -ForegroundColor Red - - $buildId = [regex]::Matches($appBuildOutput, "(?<=Queued a build with ID: )[\w]*").Value.Trim() - - $buildLogs = Get-BuildLogs ` - -SubscriptionId (Get-AzContext).Subscription.Id ` - -ResourceGroupName $ResourceGroupName ` - -RegistryName $acrName ` - -BuildId $buildId - - $buildLogs | Out-File -FilePath $errorLog -Append - - $script:containerBuildError = $true - } else { - Write-Host "INFO: App container image build and push completed successfully" -ForegroundColor Green - } - - Write-Host "INFO: Restarting App Service" -ForegroundColor Green - - Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName - } - - if(-not $containerBuildError) { - Write-Host "INFO: Azure IPAM Solution updated successfully" -ForegroundColor Green - } else { - Write-Host "WARNING: Azure IPAM Solution deployed with errors, see logs for details!" -ForegroundColor Yellow - Write-Host "Run Log: $transcriptLog" -ForegroundColor Yellow - Write-Host "Error Log: $errorLog" -ForegroundColor Yellow - } - } else { - if (-Not $ZipFilePath) { - if (-Not $AssetFolder) { - try { - # Create a temporary folder path - $TempFolder = Join-Path -Path TEMP:\ -ChildPath $(New-Guid) - - # Create directory if not exists - $script:TempFolderObj = New-Item -ItemType Directory -Path $TempFolder -Force - - $script:AssetFolder = $TempFolderObj - } catch { - Write-Host "ERROR: Unable to create temp directory to store ZIP archive!" -ForegroundColor Red - throw $_ - } - } else { - $script:AssetFolder = Get-Item -Path $AssetFolder - } - - Write-Host "INFO: Fetching latest ZIP Deploy archive..." -ForegroundColor Green - - Get-ZipFile -GitHubUserName $GitHubUserName -GitHubRepoName $GitHubRepoName -ZipFileName $ZipFileName -AssetFolder $AssetFolder - - $script:ZipFilePath = Join-Path -Path $AssetFolder.FullName -ChildPath $ZipFileName - } else { - $script:ZipFilePath = Get-Item -Path $ZipFilePath - } - - Write-Host "INFO: Uploading ZIP Deploy archive..." -ForegroundColor Green - - try { - Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName -ZipFilePath $ZipFilePath - } catch { - Write-Host "SWITCH: Retrying ZIP Deploy with Kudu API..." -ForegroundColor Blue - Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName -ZipFilePath $ZipFilePath -UseAPI - } - - if ($TempFolderObj) { - Write-Host "INFO: Cleaning up temporary directory" -ForegroundColor Green - Remove-Item -LiteralPath $TempFolderObj.FullName -Force -Recurse -ErrorAction SilentlyContinue - $script:TempFolderObj = $null - } - - Write-Host - Write-Host "NOTE: Please allow ~5 minutes for the ZIP Deploy process to complete" -ForegroundColor Yellow - } -} -catch { - $_ | Out-File -FilePath $errorLog -Append - Write-Host "ERROR: Unable to update Azure IPAM application, see log for detailed information!" -ForegroundColor red - Write-Host "Update Log: $errorLog" -ForegroundColor Red - - if ($env:CI) { - Write-Host $_.ToString() - } - - exit 1 -} -finally { - if ($TempFolderObj) { - Remove-Item -LiteralPath $TempFolderObj.FullName -Force -Recurse -ErrorAction SilentlyContinue - } - - Write-Host - Stop-Transcript | Out-Null -} 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..56c91ce2 --- /dev/null +++ b/docs/automation/README.md @@ -0,0 +1,205 @@ +# 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 to create a reservation as a managed Terraform resource, then creates the VNet with the reserved CIDR and applies the settlement tag. See the [Azure IPAM Terraform Provider](#azure-ipam-terraform-provider) section below for more detail. +- **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. + +### 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. + +## Azure IPAM 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 and exposes it as native Terraform resources and data sources. For teams that already manage their Azure estate with Terraform, it offers a fully declarative alternative to calling the REST API directly — covering the same surface area as the three automation areas above (reservations, virtual network associations, and external networks). + +> **Note:** The provider is published and maintained by [XtratusCloud](https://github.com/XtratusCloud/terraform-provider-azureipam), not by the Azure IPAM project. It is not a Microsoft-supported component. The REST API remains the authoritative interface to Azure IPAM, and the snippets below are intended as a starting point — always refer to the [provider documentation on the Terraform Registry](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs) for the current, authoritative reference. + +### When to Use the Provider + +The provider is a good fit when: + +- Your infrastructure is already managed end-to-end with Terraform and you want IPAM state tracked the same way. +- You want reservations, associations, and external networks reconciled via `terraform plan` / `terraform apply` rather than imperative API calls in a pipeline step. +- You need Terraform's drift detection to catch out-of-band changes to IPAM objects. + +Direct REST calls (via the provided [PowerShell and Bash scripts](https://github.com/Azure/ipam/tree/main/examples/scripts) or your own tooling) remain the right choice for ad-hoc operations, non-Terraform pipelines, or environments where adding a community provider isn't acceptable. + +### Provider Configuration + +The provider authenticates against the Azure IPAM Engine using a bearer token. The recommended pattern is to acquire the token via the Azure CLI and pass it through the `AZUREIPAM_TOKEN` environment variable so it is not persisted to Terraform state: + +```hcl +terraform { + required_providers { + azureipam = { + source = "xtratuscloud/azureipam" + version = "~> 2.0" + } + } +} + +provider "azureipam" { + api_url = "https://.azurewebsites.net" + # token sourced from AZUREIPAM_TOKEN environment variable +} +``` + +```bash +export AZUREIPAM_TOKEN=$(az account get-access-token \ + --resource "api://" --query accessToken -o tsv) +terraform apply +``` + +For a working example that obtains the token inline (with the trade-offs of storing it in state), see [`examples/ipam-terraform/providers.tf`](https://github.com/Azure/ipam/blob/main/examples/ipam-terraform/providers.tf). + +### Example: Reservation and VNet + +The most common usage pattern is the **reserve → deploy → tag** workflow described above, expressed as two Terraform resources. The reservation's `cidr` output is consumed as the VNet's address space, and its `tags` output is applied to the VNet so Azure IPAM can automatically settle the reservation: + +```hcl +resource "azureipam_reservation" "vnet" { + space = "MySpace" + blocks = ["MyBlock"] + size = 24 +} + +resource "azurerm_resource_group" "rg" { + name = "my-rg" + location = "eastus" +} + +resource "azurerm_virtual_network" "network" { + name = "my-vnet" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + address_space = [azureipam_reservation.vnet.cidr] + tags = azureipam_reservation.vnet.tags +} +``` + +A complete, runnable version of this example — including variables, outputs, and provider configuration — is available under [`examples/ipam-terraform/`](https://github.com/Azure/ipam/tree/main/examples/ipam-terraform). + +### Beyond Reservations + +The provider also exposes resources and data sources for spaces, blocks, virtual network associations, and external networks. Because the resource and attribute surface evolves with the provider rather than this documentation, the [provider's Terraform Registry page](https://registry.terraform.io/providers/XtratusCloud/azureipam/latest/docs) is the authoritative reference for the full schema, available data sources, and version compatibility notes. 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..267e1af5 100644 --- a/docs/update/README.md +++ b/docs/update/README.md @@ -15,8 +15,11 @@ 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 + - [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 **Subscription** scope, required only for the [infrastructure update](#3-infrastructure-update-auto-detected) — its additive deployment is evaluated at subscription scope. If you cannot grant subscription-scope access, run the update with `-SkipInfraUpdate` to bypass this step. +- [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. @@ -29,7 +32,7 @@ The update process varies depending on your current Azure IPAM deployment archit #### Most Common Deployment Type -For deployments using the publicly hosted Azure Container Registry (`azureipam.azurecr.io`), updates are handled by simply restarting your Azure App Service or Function App to pull the latest container images. +For deployments using the publicly hosted Azure Container Registry (`registry.azureipam.com`, or the legacy `azureipam.azurecr.io`), updates are handled by simply restarting your Azure App Service or Function App to pull the latest container images. - **Update Method**: Container restart - **Downtime**: Minimal (during restart only) @@ -41,7 +44,9 @@ For deployments using a self-hosted Azure Container Registry within your subscri - **Update Method**: Container build and deployment - **Downtime**: During container build and restart -- **Additional Requirements**: Azure CLI authentication (as noted in Prerequisites above) +- **Additional Requirements**: + - Azure CLI authentication (as noted in Prerequisites above) + - The registry must reside in the App Service's resource group for automated builds (otherwise the script provides manual image-update guidance) ### 3. Native ZIP Deploy Deployments @@ -72,6 +77,9 @@ For detailed backup instructions, refer to the [Migration Guide backup section]( The update script automatically handles version compatibility, including: - **Python Version Updates**: If the target Azure IPAM version uses a different Python version, the script will automatically update your App Service configuration +- **Version Comparison**: The script compares the currently running version (via the `/api/status` endpoint) against the target version and skips the update when already up to date (override with `-Force`) + - Native deployments compare against the latest GitHub release + - Private ACR container deployments compare against the repository and skip the image build when they match - **Health Check Configuration**: Missing health check configurations will be automatically added during the update - **Legacy Detection**: Docker Compose deployments (deprecated) will be detected and the script will redirect you to the migration guide @@ -136,13 +144,13 @@ Clone the Azure IPAM repository to access the update script: ```powershell # Example using PowerShell for Windows PS C:\> git clone https://github.com/Azure/ipam.git -PS C:\> cd .\ipam\deploy -PS C:\ipam\deploy> .\update.ps1 +PS C:\> cd .\ipam\update +PS C:\ipam\update> .\update.ps1 # Example using PowerShell for Linux PS /> git clone https://github.com/Azure/ipam.git -PS /> cd /ipam/deploy -PS /ipam/deploy> .\update.ps1 +PS /> cd /ipam/update +PS /ipam/update> .\update.ps1 ``` ## Update Scenarios and Usage @@ -222,6 +230,8 @@ By default, the script automatically creates a temporary directory for downloadi | `-ZipFileName` | String | No | ZIP file name to download from GitHub **1** | | `-ZipFilePath` | String | No | Path to local ZIP file for deployment **1** | | `-AssetFolder` | String | No | Directory to store downloaded ZIP file **2** | +| `-SkipInfraUpdate` | Switch | No | Skip infrastructure updates | +| `-Force` | Switch | No | Skip confirmation prompts and force a redeploy even if already up to date | > **NOTE 1:** Only applicable for native (non-container) deployments. These parameters are ignored for container deployments, which are automatically built from the latest repository code. @@ -244,27 +254,46 @@ The update script follows this automated process and will automatically determin - Automatically adds health check configuration if missing - Sets health check failure threshold to 2 attempts -### 3. Deployment Type Detection and Processing +### 3. Infrastructure Update (Auto-Detected) + +For deployments missing infrastructure that newer Azure IPAM deployments include, the script offers an additive infrastructure update that brings the deployment in line with current deployments. The set of infrastructure it provisions evolves alongside Azure IPAM. **The production site is never modified.** + +- Skipped automatically when the deployment already includes the expected infrastructure (safe to re-run) +- Skipped (with a warning) when the environment can't support the update (for example, an unsupported App Service Plan tier or missing baseline settings) +- Discovers and verifies all dependencies (plan tier, managed identity, Cosmos/Key Vault settings) **before** deploying anything +- Prompts for confirmation before deploying any infrastructure (use `-Force` to skip the prompt, or `-SkipInfraUpdate` to bypass infrastructure updates entirely) +- Provisions resources from an additive Bicep template that mirrors production and **preserves all existing app-setting values** (only *missing* baseline keys are added) +- Replicates production networking (such as regional vNet integration) where applicable; if it can't be applied automatically, you're directed to complete it manually + +> **NOTE:** This step is independent of the code/image update below — the day-to-day update still targets **production only**. The slot is scaffolding that makes future version changes safer via slot swaps. + +### 4. Deployment Type Detection and Processing #### For Public ACR Container Deployments -- Detects use of public Azure Container Registry (`azureipam.azurecr.io`) by examining `LinuxFxVersion` +- Detects use of public Azure Container Registry (`registry.azureipam.com`, or the legacy `azureipam.azurecr.io`) by examining `LinuxFxVersion` - Simply restarts the application to pull latest container images from public registry - **Process exits here** - no building or ZIP deployment needed #### For Private ACR Container Deployments -- Validates private ACR exists in the same Resource Group +- Locates the private ACR in the App Service's resource group + - The script only attempts an automated build when the registry is in that resource group; if it is located elsewhere, the script outlines the manual image-update steps and exits cleanly (see [Container Build Failures](#container-build-failures)) +- Compares the running version (via `/api/status`) against the repository and **skips the image build when already up to date** (override with `-Force`) - 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`) +- Tags images with both the current version and `latest` (`ipam:` + `ipam:latest`, or `ipamfunc:` + `ipamfunc:latest`) and pushes them to the private registry - Restarts the application - Captures and logs build errors if container build fails #### For Native ZIP Deploy Deployments +- Compares the currently running version (via the `/api/status` endpoint) against the latest GitHub release and **skips the deployment if already up to date** + - Use `-Force` to redeploy anyway + - This check is also skipped when a local `-ZipFilePath` is supplied - Checks and updates Python version if it has changed between versions - Downloads latest release ZIP from GitHub (using GitHubUserName/GitHubRepoName parameters) - Alternatively uses provided local ZIP file if ZipFilePath is specified @@ -274,12 +303,82 @@ The update script follows this automated process and will automatically determin - Handles retry logic for deployment failures (3 attempts) - Cleans up temporary files -### 4. Restart and Validation +### 5. Restart and Validation - Restarts the App Service or Function App - Implements retry logic for restart failures - Provides status updates throughout the process +## Staging Slots + +Current Azure IPAM deployments provision a **`staging` [deployment slot](https://learn.microsoft.com/azure/app-service/deploy-staging-slots)** alongside the production site. + +This additional deployment slot: + +- Runs on the **same App Service Plan** at no additional plan cost +- Reuses the **same managed identity**, so it inherits Key Vault and Cosmos DB access automatically +- Is created **disabled** so it consumes no runtime capacity until needed +- Mirrors production, making it a safe target for future **slot-swap** upgrades (deploy to the slot, validate, then swap) + +When you run `update.ps1` against an older deployment that predates slots, the script auto-detects the missing slot and offers an infrastructure update to add it (see [Infrastructure Update](#3-infrastructure-update-auto-detected)). Use `-SkipInfraUpdate` to bypass it, or `-Force` to add it without the confirmation prompt. + +> **NOTE:** Adding the slot is **additive** — your production site is never modified, and your existing app-setting values are always preserved. Only missing baseline settings are backfilled on the slot. + +### vNet Integration on the Staging Slot + +If your production App Service or Function App uses **regional vNet integration**, the update replicates that integration onto the new slot automatically. If it cannot be applied automatically (for example, due to subnet capacity, delegation, or permissions), the slot is still created **without** vNet integration and the script reports the production subnet details (writing the full subnet resource ID to the log) for manual remediation. + +To add vNet integration to the `staging` slot manually: + +#### Azure Portal + +1. Navigate to your App Service / Function App → **Deployment slots** → select the **staging** slot +2. Go to **Networking** → **vNet integration** → **Add vNet integration** +3. Select the **same vNet and subnet** used by the production site, then **Connect** + +#### Azure CLI + +```bash +az webapp vnet-integration add \ + --resource-group "" \ + --name "" \ + --slot staging \ + --vnet "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/" \ + --subnet "" +``` + +#### Azure PowerShell + +```powershell +# Full resource ID of the subnet used by the production site +$subnetId = "/subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks//subnets/" + +$slot = Get-AzResource ` + -ResourceGroupName "" ` + -ResourceType "Microsoft.Web/sites/slots" ` + -ResourceName "/staging" + +$slot.Properties | Add-Member -NotePropertyName "virtualNetworkSubnetId" -NotePropertyValue $subnetId -Force + +$slot | Set-AzResource -Force +``` + +> **NOTE:** The subnet must be delegated to **`Microsoft.Web/serverFarms`**. The Azure CLI command adds this delegation automatically; with the Azure Portal or Azure PowerShell, ensure the delegation is in place first. + +> **NOTE:** If your production site routes all outbound traffic through the vNet (the `vnetRouteAllEnabled` site property, shown in the portal under **Networking** → **Outbound traffic configuration**), apply the same setting to the `staging` slot. Otherwise, outbound connectivity that depends on the vNet — for example, reaching private-endpoint-only Cosmos DB or Key Vault — could break after a slot swap. + +> **NOTE:** Regional vNet integration is configured **per slot**. The slot stays disabled until you explicitly start and swap it, so this can be completed at any time before your first slot-based upgrade. + +### Optional: Removing the Legacy `COSMOS-KEY` Secret + +Deployments originally created with very old Azure IPAM versions (e.g. v3.0.0) may have an unused `COSMOS-KEY` secret in Key Vault left over from key-based Cosmos DB authentication. Current Azure IPAM uses managed identity exclusively, so this secret is no longer referenced. It is harmless to leave in place, but if you wish to clean it up: + +```powershell +Remove-AzKeyVaultSecret -VaultName "" -Name "COSMOS-KEY" +``` + +> **WARNING:** Only remove this secret if you are certain no custom automation references it. + ## Monitoring the Update Process ### Update Logs @@ -313,7 +412,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 @@ -399,7 +498,7 @@ az account set --subscription "your-subscription-id" #### Container Build Failures -**Issue**: Private ACR container build fails +**Issue**: Private ACR container build fails, or the registry is located outside the App Service's resource group **Solution**: @@ -407,7 +506,9 @@ 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) + +> **NOTE:** The update script will only attempt an automated build when the private ACR resides in the **same resource group** as the App Service. If your registry is in a different resource group, the script skips the build and directs you here — build and push a new image manually using the Contributing Guide instructions above, then restart the App Service or Function App to pull the new image. #### ZIP Deploy Failures @@ -425,8 +526,12 @@ az account set --subscription "your-subscription-id" **Issue**: Script detects Docker Compose deployment ```text -WARNING: Legacy Docker Compose detected! -Please follow the migration guide... +Manual Migration Required +This deployment uses the legacy Docker Compose configuration, which is no longer +supported and cannot be updated automatically. + +To migrate to the current single-container deployment, follow the migration guide: + https://azure.github.io/ipam/#/migration/README ``` **Solution**: Use the [Migration Guide](/migration/README.md) instead of the update script diff --git a/engine/Dockerfile.deb b/engine/Dockerfile.deb index c6a11f7c..5b13ba02 100644 --- a/engine/Dockerfile.deb +++ b/engine/Dockerfile.deb @@ -1,33 +1,49 @@ ARG BASE_IMAGE=python:3.11-slim FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=80 # Set Debian Frontend to Non-Interactive ARG DEBIAN_FRONTEND=noninteractive +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} # Disable PIP Root Warnings ENV PIP_ROOT_USER_ACTION=ignore +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Engine" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Set the Working Directory WORKDIR /ipam # Copy Requirements File -ADD ./requirements.lock.txt . +COPY ./requirements.txt . +COPY ./requirements.lock.txt . # Upgrade PIP -RUN pip install --upgrade pip --progress-bar off +RUN pip install --no-cache-dir --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 -ADD ./init.sh . +COPY ./app ./app +COPY ./init.sh . # Set Script Execute Permissions RUN chmod +x init.sh @@ -36,4 +52,4 @@ RUN chmod +x init.sh EXPOSE $PORT # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/engine/Dockerfile.dev b/engine/Dockerfile.dev index 978a4977..cb5d1e92 100644 --- a/engine/Dockerfile.dev +++ b/engine/Dockerfile.dev @@ -14,7 +14,7 @@ ENV PORT=$PORT COPY ./requirements.txt /code/requirements.txt # Upgrade PIP -RUN pip install --upgrade pip --progress-bar off +RUN pip install --no-cache-dir --upgrade pip --progress-bar off # Install Dependencies RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt @@ -26,4 +26,4 @@ COPY ./app /code/app EXPOSE $PORT # Execute Startup Script -CMD uvicorn "app.main:app" --reload --host "0.0.0.0" --port $PORT +CMD ["/bin/sh", "-c", "uvicorn app.main:app --reload --host 0.0.0.0 --port $PORT"] diff --git a/engine/Dockerfile.func b/engine/Dockerfile.func index 1cabef95..c89f7eac 100644 --- a/engine/Dockerfile.func +++ b/engine/Dockerfile.func @@ -2,18 +2,36 @@ # 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 + +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Azure Function Root Directory & Enable Console Logging ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ AzureFunctionsJobHost__Logging__Console__IsEnabled=true +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Engine Function" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + +WORKDIR /home/site/wwwroot + # Copy Requirements File -ADD ./requirements.lock.txt . +COPY ./requirements.txt . +COPY ./requirements.lock.txt . # Upgrade PIP -RUN pip install --upgrade pip --progress-bar off +RUN pip install --no-cache-dir --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..77055042 100644 --- a/engine/Dockerfile.rhel +++ b/engine/Dockerfile.rhel @@ -1,15 +1,26 @@ -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 +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} # Disable PIP Root Warnings ENV PIP_ROOT_USER_ACTION=ignore +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Engine" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Set the Working Directory WORKDIR /ipam @@ -17,17 +28,22 @@ WORKDIR /ipam USER root # Copy Requirements File -ADD ./requirements.lock.txt /ipam +COPY ./requirements.txt /ipam +COPY ./requirements.lock.txt /ipam # Upgrade PIP -RUN pip install --upgrade pip --progress-bar off +RUN pip install --no-cache-dir --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 -ADD ./init.sh . +COPY ./app ./app +COPY ./init.sh . # Set Script Execute Permissions RUN chmod +x init.sh @@ -40,4 +56,4 @@ USER 1001 EXPOSE $PORT # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/engine/app/dependencies.py b/engine/app/dependencies.py index d8d9c64e..4a228253 100644 --- a/engine/app/dependencies.py +++ b/engine/app/dependencies.py @@ -1,21 +1,17 @@ -from fastapi import Request, HTTPException -# from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from requests import Session, adapters -from urllib3.util.retry import Retry -from cryptography.hazmat.primitives import serialization - -import jwt import copy import json -from app.routers.common.helper import ( - cosmos_query -) +import jwt +from cryptography.hazmat.primitives import serialization +from fastapi import HTTPException, Request +from requests import Session, adapters +from urllib3.util.retry import Retry from app.globals import globals - from app.logs.logs import ipam_logger as logger +from app.routers.common.helper import cosmos_query + +# from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer # class IPAMToken(HTTPBearer): # _session = None @@ -39,7 +35,7 @@ # raise HTTPException(status_code=403, detail="Invalid authorization code.") # async def fetch_jwks_keys(self): -# if self._session is None: +# if self._session is None: # self._session = Session() # retries = Retry( @@ -104,7 +100,7 @@ # if rsa_key: # rsa_pem_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(rsa_key)) # rsa_pem_key_bytes = rsa_pem_key.public_bytes( -# encoding=serialization.Encoding.PEM, +# encoding=serialization.Encoding.PEM, # format=serialization.PublicFormat.SubjectPublicKeyInfo # ) @@ -141,7 +137,7 @@ async def fetch_jwks_keys(): global _session - if _session is None: + if _session is None: _session = Session() retries = Retry( @@ -196,8 +192,8 @@ async def validate_token(request: Request): "e": key["e"] } except Exception as e: - logger.error("Unable to parse authorization token."); - logger.error(e); + logger.error("Unable to parse authorization token.") + logger.error(e) raise HTTPException(status_code=401, detail="Unable to parse authorization token.") try: @@ -213,7 +209,7 @@ async def validate_token(request: Request): if rsa_key: rsa_pem_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(rsa_key)) rsa_pem_key_bytes = rsa_pem_key.public_bytes( - encoding=serialization.Encoding.PEM, + encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) diff --git a/engine/app/globals.py b/engine/app/globals.py index 9f405b58..8a3f61db 100644 --- a/engine/app/globals.py +++ b/engine/app/globals.py @@ -1,11 +1,17 @@ -import os import json -import aiohttp +import os +from datetime import datetime, timezone +import aiohttp from azure.core.pipeline.transport import AioHttpTransport ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +# Process start time (ISO 8601, UTC), captured once when this module is first +# imported at engine startup. Exposed via /api/status so clients can detect a +# restart: the value changes when a new process comes up. +_START_TIME = datetime.now(timezone.utc) + AZURE_ENV_MAP = { 'AZURE_PUBLIC': { 'AZURE_ARM': 'management.azure.com', @@ -36,14 +42,23 @@ class Globals: def __init__(self): - conn = aiohttp.TCPConnector(limit=100) - session = aiohttp.ClientSession(connector=conn) - self.shared_transport = AioHttpTransport(session=session, session_owner=False) + self._shared_transport = None @property def IPAM_VERSION(self): return json.load(open(os.path.join(ROOT_DIR, "version.json")))['app'] + @property + def SCHEMA_VERSION(self): + # Monotonic integer, decoupled from the app SemVer. Bumped only when the + # Cosmos document shapes change. Used for the startup compatibility gate + # and to decide whether a production-slot convergence pass is needed. + return json.load(open(os.path.join(ROOT_DIR, "version.json")))['schema'] + + @property + def START_TIME(self): + return _START_TIME.isoformat(timespec='seconds').replace('+00:00', 'Z') + @property def MANAGED_IDENTITY_ID(self): return os.environ.get('MANAGED_IDENTITY_ID') @@ -110,11 +125,30 @@ def CONTAINER_NAME(self): ctr_name = os.environ.get('CONTAINER_NAME') return ctr_name if ctr_name else 'ipam-ctr' - + @property def SHARED_TRANSPORT(self): - return self.shared_transport - + if self._shared_transport is None: + conn = aiohttp.TCPConnector(limit=100) + session = aiohttp.ClientSession(connector=conn) + self._shared_transport = AioHttpTransport(session=session, session_owner=False) + + return self._shared_transport + + @property + def SLOT_NAME(self): + # App Service sets this to 'Production' for the production slot and the + # slot name (e.g. 'staging') otherwise. Unset for container/k8s/local. + return os.environ.get('WEBSITE_SLOT_NAME') + + @property + def IS_PRODUCTION_SLOT(self): + # Treat 'unset' as production so non-slot deployments (container 'latest', + # zip, Kubernetes, local) always run with full functionality. + slot = os.environ.get('WEBSITE_SLOT_NAME') + + return slot is None or slot.lower() == 'production' + @property def DEPLOYMENT_STACK(self): ipam_stack = "" diff --git a/engine/app/logs/logs.py b/engine/app/logs/logs.py index 5ec8c807..ed5fae36 100644 --- a/engine/app/logs/logs.py +++ b/engine/app/logs/logs.py @@ -1,9 +1,8 @@ +import json +import logging import os import sys -import logging -import json import tempfile - from pathlib import Path from loguru import logger diff --git a/engine/app/main.py b/engine/app/main.py index 971256be..7bab22af 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -1,50 +1,40 @@ -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import JSONResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from fastapi.exceptions import HTTPException as StarletteHTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware -from fastapi.encoders import jsonable_encoder - -from azure.identity.aio import ManagedIdentityCredential - -from azure.cosmos.aio import CosmosClient -from azure.cosmos import PartitionKey -from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError, CosmosHttpResponseError - -from app.routers import ( - azure, - internal, - admin, - user, - space, - tool, - status -) - -from app.logs.logs import ipam_logger as logger - -import os -import re -import uuid -import copy import json +import os import shutil import tempfile import traceback -import requests +import uuid +from contextlib import asynccontextmanager from pathlib import Path from urllib.parse import urlparse -from contextlib import asynccontextmanager + +import requests from apscheduler.schedulers.asyncio import AsyncIOScheduler +from azure.cosmos import PartitionKey +from azure.cosmos.aio import CosmosClient +from azure.cosmos.exceptions import CosmosHttpResponseError +from azure.identity.aio import ManagedIdentityCredential +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import HTTPException as StarletteHTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles from app.globals import globals - -from app.routers.common.helper import ( - cosmos_query, - cosmos_upsert, - cosmos_replace +from app.logs.logs import ipam_logger as logger +from app.routers import ( + admin, + azure, + health, + internal, + notifications, + space, + status, + tool, + user, ) +from app.schema import check_compatibility, run_convergence ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) BUILD_DIR = os.path.join(os.getcwd(), "dist") @@ -52,7 +42,7 @@ try: UI_APP_ID = uuid.UUID(os.environ.get('UI_APP_ID')) VALID_APP_ID = UI_APP_ID != uuid.UUID(int=0) -except: +except Exception: UI_APP_ID = None VALID_APP_ID = False @@ -60,6 +50,15 @@ Azure IPAM is a lightweight solution developed on top of the Azure platform designed to help Azure customers manage their enterprise IP Address space easily and effectively. """ +# Operational modes set during startup and enforced by the service-mode gate. +SERVICE_MODE_PRODUCTION = "production" +SERVICE_MODE_STAGING = "staging" +SERVICE_MODE_INCOMPATIBLE = "incompatible" + +# API paths that remain reachable even when the engine is not in production mode +# (so a deploy pipeline can verify health/version on the staging slot). +ALLOWED_API_PATHS = {"/api/status", "/api/health"} + async def ipam_init(): global BUILD_DIR @@ -124,23 +123,21 @@ async def ipam_init(): except CosmosHttpResponseError as e: logger.error('Cosmos database does not exist, error initializing Azure IPAM!') raise e - - + + database = cosmos_client.get_database_client(database_name) container_name = globals.CONTAINER_NAME try: logger.info('Verifying Container Exists...') - container = await database.create_container_if_not_exists( + await database.create_container_if_not_exists( id = container_name, partition_key = PartitionKey(path = "/tenant_id") ) except CosmosHttpResponseError as e: logger.error('Cosmos container does not exist, error initializing Azure IPAM!') raise e - - container = database.get_container_client(container_name) await cosmos_client.close() await managed_identity_credential.close() @@ -152,243 +149,12 @@ async def ipam_init(): "env": globals.AZURE_ENV } - try: - requests.post(url = "https://azureipammetrics.azurewebsites.net/api/heartbeat", json = hb_message) - except Exception: - pass - -async def upgrade_db(): - managed_identity_credential = ManagedIdentityCredential( - client_id = globals.MANAGED_IDENTITY_ID - ) - - cosmos_client = CosmosClient( - globals.COSMOS_URL, - credential=globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential, - transport=globals.SHARED_TRANSPORT - ) - - database_name = globals.DATABASE_NAME - database = cosmos_client.get_database_client(database_name) - - container_name = globals.CONTAINER_NAME - container = database.get_container_client(container_name) - - try: - spaces_query = await container.read_item("spaces", partition_key="spaces") - - for space in spaces_query['spaces']: - if 'vnets' in space: - del space['vnets'] - - new_space = { - "id": uuid.uuid4(), - "type": "space", - "tenant_id": globals.TENANT_ID, - **space - } - - await cosmos_upsert(jsonable_encoder(new_space)) - - await container.delete_item("spaces", partition_key = "spaces") - - logger.warning('Spaces database conversion complete!') - except CosmosResourceNotFoundError: - logger.info('No existing spaces to convert...') - pass - - try: - users_query = await container.read_item("users", partition_key="users") - - for user in users_query['users']: - new_user = { - "id": uuid.uuid4(), - "type": "user", - "tenant_id": globals.TENANT_ID, - "data": user - } - - await cosmos_upsert(jsonable_encoder(new_user)) - - await container.delete_item("users", partition_key = "users") - - logger.warning('Users database conversion complete!') - except CosmosResourceNotFoundError: - logger.info('No existing users to convert...') - pass - - try: - admins_query = await container.read_item("admins", partition_key="admins") - - admin_data = { - "id": uuid.uuid4(), - "type": "admin", - "tenant_id": globals.TENANT_ID, - "admins": admins_query['admins'], - "exclusions": [] - } - - await cosmos_upsert(jsonable_encoder(admin_data)) - - await container.delete_item("admins", partition_key = "admins") - - logger.warning('Admins database conversion complete!') - except CosmosResourceNotFoundError: - logger.info('No existing admins to convert...') - pass - - user_fixup_query = await cosmos_query("SELECT * FROM c WHERE (c.type = 'user' AND (NOT IS_DEFINED(c['data']['darkMode']) OR NOT IS_DEFINED(c['data']['views'])))", globals.TENANT_ID) - - if user_fixup_query: - for user in user_fixup_query: - user_data = copy.deepcopy(user) - - if 'darkMode' not in user_data['data']: - user_data['data']['darkMode'] = False - - if 'views' not in user_data['data']: - user_data['data']['views'] = {} - - await cosmos_replace(user, user_data) - - logger.warning('User object patching complete!') - else: - logger.info("No existing user objects to patch...") - - admin_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN admin IN c.admins WHERE (c.type = 'admin' AND NOT IS_DEFINED(admin.type))", globals.TENANT_ID) - - if admin_fixup_query: - admin_data = copy.deepcopy(admin_fixup_query[0]) - - for i, admin in enumerate(admin_data['admins']): - if 'type' not in admin: - admin_data['admins'][i] = { - "type": "User", - "name": admin['name'], - "email": admin['email'], - "id": admin['id'] - } - - await cosmos_replace(admin_fixup_query[0], admin_data) - - logger.warning('Admin object patching complete!') - else: - logger.info("No existing admin objects to patch...") - - resv_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks JOIN resv in block.resv WHERE (c.type = 'space' AND NOT IS_DEFINED(resv.settledOn))", globals.TENANT_ID) - - if resv_fixup_query: - for space in resv_fixup_query: - space_data = copy.deepcopy(space) - - for block in space_data['blocks']: - for i, resv in enumerate(block['resv']): - if 'settledOn' not in resv: - block['resv'][i] = { - "id": resv['id'], - "cidr": resv['cidr'], - "desc": resv['desc'] if 'desc' in resv else None, - "createdOn": resv['createdOn'], - "createdBy": resv['userId'], - "settledOn": None, - "settledBy": None, - "status": resv['status'] - } - - await cosmos_replace(space, space_data) - - logger.warning('Reservation patching complete!') - else: - logger.info("No existing reservations to patch...") - - external_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks WHERE (c.type = 'space' AND NOT IS_DEFINED(block.externals))", globals.TENANT_ID) - - if external_fixup_query: - for space in external_fixup_query: - space_data = copy.deepcopy(space) - - new_blocks = [] - - for block in space_data['blocks']: - new_block = { - "name": block['name'], - "cidr": block['cidr'], - "vnets": block['vnets'], - "externals": [], - "resv": block['resv'] - } - - new_blocks.append(new_block) - - space_data['blocks'] = new_blocks - - await cosmos_replace(space, space_data) - - logger.warning('External networks patching complete!') - else: - logger.info("No existing external networks to patch...") - - subnet_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks JOIN ext in block.externals WHERE (c.type = 'space' AND NOT IS_DEFINED(ext.subnets))", globals.TENANT_ID) - - if subnet_fixup_query: - for space in subnet_fixup_query: - space_data = copy.deepcopy(space) - - for block in space_data['blocks']: - new_externals = [] - - for external in block['externals']: - - new_external = { - "name": external['name'], - "desc": external['desc'], - "cidr": external['cidr'], - "subnets": [] - } - - new_externals.append(new_external) - - block['externals'] = new_externals - - await cosmos_replace(space, space_data) - - logger.warning('External subnet patching complete!') - else: - logger.info("No existing external subnets to patch...") - - # vhub_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks JOIN vnet in block.vnets WHERE (c.type = 'space' AND RegexMatch (vnet.id, '/Microsoft.Network/virtualHubs/', ''))", globals.TENANT_ID) - - # if vhub_fixup_query: - # for space in vhub_fixup_query: - # space_data = copy.deepcopy(space) - - # new_blocks = [] - - # for block in space_data['blocks']: - # vnets = [x for x in block['vnets'] if re.match(".*/Microsoft.Network/virtualNetworks/.*", x['id'])] - # vhubs = [x for x in block['vnets'] if re.match(".*/Microsoft.Network/virtualHubs/.*", x['id'])] - - # new_block = { - # "name": block['name'], - # "cidr": block['cidr'], - # "vnets": vnets, - # "vhubs": vhubs, - # "external": block['external'], - # "resv": block['resv'] - # } - - # new_blocks.append(new_block) - - # space_data['blocks'] = new_blocks - - # await cosmos_replace(space, space_data) - - # logger.warning('Virtual Hub patching complete!') - # else: - # logger.info("No existing Virtual Hubs to patch...") - - await cosmos_client.close() - await managed_identity_credential.close() + # Only the production slot phones home; a staging slot stays inert. + if globals.IS_PRODUCTION_SLOT: + try: + requests.post(url = "https://metrics.azureipam.com/api/heartbeat", json = hb_message, timeout = 15) + except Exception: + pass async def find_reservations(): if not os.environ.get("FUNCTIONS_WORKER_RUNTIME"): @@ -402,19 +168,41 @@ async def find_reservations(): @asynccontextmanager async def lifespan(app: FastAPI): - # IPAM Startup Tasks + # IPAM Startup Tasks (also acts as a Cosmos / managed-identity connectivity probe). await ipam_init() - await upgrade_db() - # Schedule Recurring Tasks - scheduler = AsyncIOScheduler() - scheduler.add_job(func=find_reservations, trigger='interval', minutes=1) - scheduler.start() + # Compatibility gate (runs in every slot): refuse to serve if this build is + # older than the database's minimum readable schema. + compat = await check_compatibility() + app.state.compat = compat + + scheduler = None + + if not compat.compatible: + app.state.service_mode = SERVICE_MODE_INCOMPATIBLE + logger.error("Engine starting in INCOMPATIBLE mode; API disabled. %s", compat.detail) + elif not globals.IS_PRODUCTION_SLOT: + app.state.service_mode = SERVICE_MODE_STAGING + logger.warning( + "Engine starting in the '%s' slot; API disabled until swap to production.", + globals.SLOT_NAME, + ) + else: + app.state.service_mode = SERVICE_MODE_PRODUCTION + + # Production slot only: bring the data up to this build's schema, then + # start background work. Staging never mutates the shared database. + await run_convergence() + + scheduler = AsyncIOScheduler() + scheduler.add_job(func=find_reservations, trigger='interval', minutes=1) + scheduler.start() yield # IPAM Shutdown Tasks - scheduler.shutdown() + if scheduler: + scheduler.shutdown() app = FastAPI( title = "Azure IPAM", @@ -465,6 +253,16 @@ async def lifespan(app: FastAPI): prefix = "/api" ) +app.include_router( + notifications.router, + prefix = "/api" +) + +app.include_router( + health.router, + prefix = "/api" +) + app.include_router( status.router, prefix = "/api" @@ -511,6 +309,38 @@ async def serve_react_app(request: Request): async def http_exception_handler(request, exc): return JSONResponse({"error": str(exc.detail)}, status_code=exc.status_code) +@app.middleware("http") +async def service_mode_gate(request: Request, call_next): + """ + When the engine is not in production mode (running in a staging slot, or + incompatible with the database schema), short-circuit all /api requests + except the status endpoint. Static UI paths are unaffected. + """ + + mode = getattr(request.app.state, "service_mode", SERVICE_MODE_PRODUCTION) + + if mode != SERVICE_MODE_PRODUCTION: + path = request.url.path + + if path.startswith("/api") and path not in ALLOWED_API_PATHS: + if mode == SERVICE_MODE_INCOMPATIBLE: + compat = getattr(request.app.state, "compat", None) + + return JSONResponse( + { + "error": "Azure IPAM is running code incompatible with the current database schema.", + "detail": compat.detail if compat else None, + }, + status_code=503, + ) + + return JSONResponse( + {"error": "Azure IPAM is currently in staging mode."}, + status_code=503, + ) + + return await call_next(request) + if os.path.isdir(BUILD_DIR) and UI_APP_ID and VALID_APP_ID: app.mount( "/assets/", @@ -523,7 +353,7 @@ async def http_exception_handler(request, exc): response_class = FileResponse, include_in_schema = False ) - def read_index(request: Request): + def read_root(request: Request): return FileResponse(BUILD_DIR + "/index.html") @app.get( diff --git a/engine/app/models.py b/engine/app/models.py index 61768038..3ae75d14 100644 --- a/engine/app/models.py +++ b/engine/app/models.py @@ -1,27 +1,18 @@ -from pydantic_core import CoreSchema, core_schema -from pydantic.json_schema import JsonSchemaValue +from typing import Annotated, Any, Dict, List, Literal, Optional, Union +from uuid import UUID +from netaddr import IPAddress, IPNetwork from pydantic import ( - GetCoreSchemaHandler, - GetJsonSchemaHandler, - ConfigDict, BaseModel, + ConfigDict, EmailStr, - model_validator -) - -from typing import ( - Annotated, - Optional, - Union, - Literal, - List, - Dict, - Any + GetCoreSchemaHandler, + GetJsonSchemaHandler, + model_validator, ) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, core_schema -from netaddr import IPNetwork, IPAddress -from uuid import UUID class IPv4Network(str): """ @@ -55,7 +46,7 @@ def validate(cls, input_value: Any): raise TypeError('string required') try: m = IPNetwork(input_value) - except: + except Exception: m = None if not m: raise ValueError('invalid ip network format') @@ -93,7 +84,7 @@ def validate(cls, input_value: Any): raise TypeError('string required') try: m = IPAddress(input_value) - except: + except Exception: m = None if not m: raise ValueError('invalid ip address format') @@ -245,7 +236,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 +561,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""" @@ -668,6 +662,22 @@ class CIDRCheckRes(BaseModel): prefixes: List[IPv4Network] containers: List[CIDRContainer] +##################### +# HEALTH MODELS # +##################### + +class HealthCheck(BaseModel): + """DOCSTRING""" + + ok: bool + detail: Optional[str] = None + +class Health(BaseModel): + """DOCSTRING""" + + ok: bool + checks: Dict[str, HealthCheck] + ##################### # STATUS MODELS # ##################### @@ -685,4 +695,8 @@ class Status(BaseModel): status: str version: str - container: ImageDetails + stack: str + environment: str + start_time: str + mode: Optional[str] = None + container: Optional[ImageDetails] = None diff --git a/engine/app/notifications/__init__.py b/engine/app/notifications/__init__.py new file mode 100644 index 00000000..afca2cbf --- /dev/null +++ b/engine/app/notifications/__init__.py @@ -0,0 +1,59 @@ +""" +Notification framework. + +``get_notifications()`` runs every registered detector, isolates failures (one +bad detector never breaks the endpoint), and aggregates the results into a +single list. This is the single seam a future remote provider slots into. +""" + +import asyncio + +from app.logs.logs import ipam_logger as logger +from app.notifications.detectors import DETECTORS, RESOLVERS +from app.notifications.models import ( + Notification, + NotificationAction, + NotificationActionResult, + NotificationList, +) + +__all__ = [ + "Notification", + "NotificationAction", + "NotificationActionResult", + "NotificationList", + "get_notifications", + "get_resolver", +] + +# Upper bound on a single detector so a hung dependency can't stall the endpoint. +DETECTOR_TIMEOUT = 10 + + +def get_resolver(notification_id): + """Return the resolve handler for a notification id, or None if it has no live remediation.""" + + return RESOLVERS.get(notification_id) + + +async def _run_detector(detector): + """Run one detector, bounded and error-isolated. Returns a list (possibly empty).""" + + try: + result = await asyncio.wait_for(detector(), timeout=DETECTOR_TIMEOUT) + + return result or [] + except Exception as e: + logger.error("Notification detector '{}' failed: {}".format(getattr(detector, "__module__", detector), e)) + + return [] + + +async def get_notifications(): + """Aggregate notifications from all registered detectors.""" + + results = await asyncio.gather(*(_run_detector(d) for d in DETECTORS)) + + notifications = [item for group in results for item in group] + + return NotificationList(notifications=notifications) diff --git a/engine/app/notifications/appservice.py b/engine/app/notifications/appservice.py new file mode 100644 index 00000000..6d55391c --- /dev/null +++ b/engine/app/notifications/appservice.py @@ -0,0 +1,176 @@ +""" +Internal Azure App Service / Function App management helpers. + +These wrap the deployment's own ``Microsoft.Web/sites`` configuration using the +managed identity (which holds Contributor on the resource group). They are used +by notification detectors (to read state) and notification resolve handlers (to +remediate). There are no public routes here — all mutation happens behind a +notification's server-owned ``resolve`` handler. +""" + +import os +import re + +import aiohttp +from azure.identity.aio import ManagedIdentityCredential +from azure.mgmt.web.aio import WebSiteManagementClient +from fastapi import HTTPException + +from app.globals import globals +from app.logs.logs import ipam_logger as logger + + +def get_site_context(): + """Resolve the current App Service / Function site (subscription, resource group, name) from the platform environment.""" + + site_name = os.environ.get("WEBSITE_SITE_NAME") + resource_group = os.environ.get("WEBSITE_RESOURCE_GROUP") + + # WEBSITE_OWNER_NAME format: "{subscriptionId}+{resourceGroup}-{region}webspace..." + owner_name = os.environ.get("WEBSITE_OWNER_NAME", "") + subscription_id = owner_name.split("+")[0] if "+" in owner_name else None + + if not all((site_name, resource_group, subscription_id)): + raise HTTPException( + status_code=500, + detail="Unable to resolve the App Service site context from the environment." + ) + + return subscription_id, resource_group, site_name + + +def get_web_client(subscription_id): + """Create a WebSite management client authenticated as the deployment's managed identity (which holds Contributor on the resource group).""" + + azure_arm_url = 'https://{}'.format(globals.AZURE_ARM_URL) + azure_arm_scope = '{}/.default'.format(azure_arm_url) + + credential = ManagedIdentityCredential( + client_id=globals.MANAGED_IDENTITY_ID + ) + + return WebSiteManagementClient( + credential=credential, + subscription_id=subscription_id, + base_url=azure_arm_url, + credential_scopes=[azure_arm_scope], + transport=globals.SHARED_TRANSPORT + ) + + +def is_container(): + """Return True if the deployment is container-based (vs. native code).""" + + return globals.DEPLOYMENT_STACK.endswith("Container") + + +def parse_image(linux_fx_version): + """Parse the container registry and full image reference from a LinuxFxVersion value (e.g. 'DOCKER|registry.azureipam.com/ipam:latest').""" + + if not linux_fx_version or "|" not in linux_fx_version: + return None, None + + prefix, image = linux_fx_version.split("|", 1) + + if prefix.upper() != "DOCKER": + return None, None + + registry = image.split("/", 1)[0] + + return registry, image + + +def split_repository_tag(image): + """Split a full image reference (registry/repository:tag) into (repository, tag).""" + + repo_and_tag = image.split("/", 1)[1] if "/" in image else image + + if ":" in repo_and_tag: + repository, tag = repo_and_tag.rsplit(":", 1) + else: + repository, tag = repo_and_tag, "latest" + + return repository, tag + + +async def read_current_registry(): + """ + Return the container registry host the deployment currently pulls from, or + None for native/unknown deployments or if the lookup fails. Safe to call from + non-request contexts (e.g. notification detectors): never raises. + """ + + if not is_container(): + return None + + try: + subscription_id, resource_group, site_name = get_site_context() + client = get_web_client(subscription_id) + + try: + config = await client.web_apps.get_configuration(resource_group, site_name) + registry, _ = parse_image(config.linux_fx_version) + + return registry + finally: + await client.close() + except Exception as e: + logger.error("Failed to read current registry: {}".format(e)) + + return None + + +async def is_image_pullable(registry, repository, tag): + """Validate that an image can be anonymously pulled from the registry by resolving an anonymous pull token and performing a manifest HEAD.""" + + base_url = "https://{}".format(registry) + + try: + async with aiohttp.ClientSession() as session: + async with session.get("{}/v2/".format(base_url)) as challenge_resp: + challenge = challenge_resp.headers.get("WWW-Authenticate", "") + + match = re.search(r'realm="(?P[^"]+)",service="(?P[^"]+)"', challenge) + + if not match: + return False + + token_params = { + "service": match.group("service"), + "scope": "repository:{}:pull".format(repository) + } + + async with session.get(match.group("realm"), params=token_params) as token_resp: + if token_resp.status != 200: + return False + + token = (await token_resp.json()).get("access_token") + + if not token: + return False + + headers = { + "Authorization": "Bearer {}".format(token), + "Accept": "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json" + } + + async with session.head("{}/v2/{}/manifests/{}".format(base_url, repository, tag), headers=headers) as manifest_resp: + return manifest_resp.status == 200 + except Exception as e: + logger.error("Image pull validation failed: {}".format(e)) + return False + + +async def apply_linux_fx_version(subscription_id, resource_group, site_name, linux_fx_version): + """Background task: set the container image reference, which triggers a restart to pull from the new registry.""" + + client = get_web_client(subscription_id) + + try: + config = await client.web_apps.get_configuration(resource_group, site_name) + config.linux_fx_version = linux_fx_version + await client.web_apps.update_configuration(resource_group, site_name, config) + except Exception as e: + logger.error("Failed to update container image reference: {}".format(e)) + finally: + await client.close() diff --git a/engine/app/notifications/detectors/__init__.py b/engine/app/notifications/detectors/__init__.py new file mode 100644 index 00000000..e486f469 --- /dev/null +++ b/engine/app/notifications/detectors/__init__.py @@ -0,0 +1,24 @@ +""" +Detector registry. + +Each detector is a self-contained module exposing an async ``detect()`` that +returns a list of ``Notification``. To add a notification, add a module here and +register its ``detect`` in ``DETECTORS`` below. +""" + +from app.notifications.detectors import registry, update + +# Ordered list of local detectors. A future remote provider aggregates into the +# same pipeline (see app.notifications.get_notifications). +DETECTORS = [ + registry.detect, + update.detect, +] + +# Map of notification id -> resolve handler, aggregated across detector modules. +# Notifications without an entry have no live remediation (link-only guidance). +RESOLVERS = { + **registry.RESOLVERS, +} + +__all__ = ["DETECTORS", "RESOLVERS"] diff --git a/engine/app/notifications/detectors/registry.py b/engine/app/notifications/detectors/registry.py new file mode 100644 index 00000000..807154bc --- /dev/null +++ b/engine/app/notifications/detectors/registry.py @@ -0,0 +1,178 @@ +""" +Registry detector. + +A deployment pulls from exactly one registry, so this single detector inspects +the current registry and emits the appropriate notification: + +* Legacy public registry (``azureipam.azurecr.io``) -> **critical**: the public + registry has moved and the deployment must migrate to keep receiving updates. +* Development registry (``azureipamdev.azurecr.io``) -> **warning**: a reminder + that the deployment is on a pre-release/testing registry and should be pointed + back at the official one. + +Both remediate the same way (repoint to the official registry via the existing +admin-gated service endpoint, which restarts the app to pull the new image). +Native deployments, the official registry, and private customer ACRs produce no +notification. +""" + +from fastapi import HTTPException + +from app.logs.logs import ipam_logger as logger +from app.notifications.appservice import ( + apply_linux_fx_version, + get_site_context, + get_web_client, + is_image_pullable, + parse_image, + read_current_registry, + split_repository_tag, +) +from app.notifications.models import ( + Notification, + NotificationAction, + NotificationActionResult, +) + +# Official Azure IPAM public registry (migration target). +OFFICIAL_REGISTRY = "registry.azureipam.com" + +# Legacy public registry customers must migrate off of. +LEGACY_REGISTRY = "azureipam.azurecr.io" + +# Development/testing registry customers should switch back off of. +DEV_REGISTRY = "azureipamdev.azurecr.io" + +DOCS_URL = "https://azure.github.io/ipam/#/update/README" + + +def _actions(notification_id): + """The shared remediation: a server-owned resolve, plus a doc link.""" + + return [ + NotificationAction( + id="migrate", + label="Switch", + kind="resolve", + method="POST", + href="/api/notifications/{}/resolve".format(notification_id), + causesRestart=True, + requiresAdmin=True, + ), + NotificationAction( + id="learn", + label="Learn more", + kind="link", + href=DOCS_URL, + ), + ] + + +async def detect(): + registry = await read_current_registry() + + if registry == LEGACY_REGISTRY: + return [ + Notification( + id="registry-migration", + severity="critical", + audience="all", + category="Deployment", + title="Action required: Azure IPAM container registry has moved", + message=( + "This deployment still pulls container images from the legacy " + "registry ({legacy}). Migrate to the current official registry " + "({official}) to keep receiving Azure IPAM updates.".format( + legacy=registry, official=OFFICIAL_REGISTRY + ) + ), + dismissible=False, + data={"currentRegistry": registry, "targetRegistry": OFFICIAL_REGISTRY}, + actions=_actions("registry-migration"), + ) + ] + + if registry == DEV_REGISTRY: + return [ + Notification( + id="registry-dev", + severity="warning", + audience="all", + category="Deployment", + title="This deployment is using a development registry", + message=( + "This deployment is pulling container images from the Azure IPAM " + "development registry ({dev}). If you were testing a pre-release " + "build, switch back to the official registry ({official}) to stay " + "on supported updates.".format(dev=registry, official=OFFICIAL_REGISTRY) + ), + dismissible=True, + data={"currentRegistry": registry, "targetRegistry": OFFICIAL_REGISTRY}, + actions=_actions("registry-dev"), + ) + ] + + return [] + + +async def resolve(background_tasks): + """ + Repoint the deployment at the official registry and restart to pull the image. + + The endpoint has already confirmed this notification is active (so the + deployment is on a remediable registry). Pre-validates the target image is + pullable before applying, so a bad target can't leave the app unable to start. + """ + + subscription_id, resource_group, site_name = get_site_context() + client = get_web_client(subscription_id) + + try: + config = await client.web_apps.get_configuration(resource_group, site_name) + current_registry, current_image = parse_image(config.linux_fx_version) + + if not current_image: + raise HTTPException( + status_code=409, + detail="Unable to determine the current container image reference." + ) + + new_linux_fx_version = config.linux_fx_version.replace(current_registry, OFFICIAL_REGISTRY, 1) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to read service configuration: {}".format(e)) + raise HTTPException(status_code=500, detail="Failed to read the service configuration.") + finally: + await client.close() + + # Pre-validate the target image is pullable BEFORE changing anything. + repository, tag = split_repository_tag(current_image) + + if not await is_image_pullable(OFFICIAL_REGISTRY, repository, tag): + raise HTTPException( + status_code=502, + detail="The image '{}/{}:{}' could not be validated for pull from the official registry; no changes were made.".format( + OFFICIAL_REGISTRY, repository, tag + ) + ) + + background_tasks.add_task( + apply_linux_fx_version, + subscription_id, + resource_group, + site_name, + new_linux_fx_version + ) + + return NotificationActionResult( + status="accepted", + detail="Registry migration accepted. The service will restart to pull images from '{}'.".format(OFFICIAL_REGISTRY) + ) + + +# Notification ids this module can remediate live -> the resolve handler. +RESOLVERS = { + "registry-migration": resolve, + "registry-dev": resolve, +} diff --git a/engine/app/notifications/detectors/update.py b/engine/app/notifications/detectors/update.py new file mode 100644 index 00000000..518fb492 --- /dev/null +++ b/engine/app/notifications/detectors/update.py @@ -0,0 +1,132 @@ +""" +Update detector. + +Compares the running Azure IPAM version against the latest published GitHub +release (``github.com/Azure/ipam``) and, when a newer version exists, emits an +informational notification pointing admins at the update guide. + +This is link-only guidance (no server-owned remediation): updating a deployment +has several supported paths, so we route the user to the docs rather than +automating it here. Any network / parse failure is swallowed and produces no +notification, so a GitHub outage never breaks the notifications pipeline. +""" + +import aiohttp + +from app.globals import globals +from app.logs.logs import ipam_logger as logger +from app.notifications.models import ( + Notification, + NotificationAction, +) + +# GitHub "latest release" endpoint; ``tag_name`` corresponds to the version. +GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/Azure/ipam/releases/latest" + +# Update guidance (options + step-by-step instructions). +UPDATE_DOCS_URL = "https://azure.github.io/ipam/#/update/README" + + +def _parse_version(text): + """Parse a ``x.y.z`` version (optionally ``v``-prefixed) into a tuple of ints, or None if it can't be parsed.""" + + if not text: + return None + + numbers = [] + + for part in text.strip().lstrip("vV").split("."): + digits = "" + + for char in part: + if char.isdigit(): + digits += char + else: + break + + if not digits: + break + + numbers.append(int(digits)) + + return tuple(numbers) if numbers else None + + +def _is_newer(latest, current): + """True if ``latest`` is a newer version than ``current`` (length-normalized tuple compare).""" + + length = max(len(latest), len(current)) + latest_padded = latest + (0,) * (length - len(latest)) + current_padded = current + (0,) * (length - len(current)) + + return latest_padded > current_padded + + +async def _get_latest_release_tag(): + """Fetch the latest release tag from GitHub. Returns None on any failure (including rate limiting), which simply yields no notification this cycle.""" + + try: + timeout = aiohttp.ClientTimeout(total=5) + + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + GITHUB_LATEST_RELEASE_URL, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "azure-ipam", + }, + ) as resp: + if resp.status != 200: + logger.warning( + "GitHub latest-release lookup returned HTTP {} (no update notification this cycle).".format(resp.status) + ) + return None + + data = await resp.json() + except Exception as e: + logger.warning("Failed to fetch latest release from GitHub: {}".format(e)) + return None + + return data.get("tag_name") + + +async def detect(): + current = _parse_version(globals.IPAM_VERSION) + + if current is None: + return [] + + tag = await _get_latest_release_tag() + latest = _parse_version(tag) + + if latest is None or not _is_newer(latest, current): + return [] + + latest_str = tag.strip().lstrip("vV") + current_str = globals.IPAM_VERSION + + return [ + Notification( + id="update-available", + severity="information", + audience="all", + category="Update", + title="A new version of Azure IPAM is available", + message=( + "Azure IPAM {latest} is available (this deployment is running {current}). " + "See the update guide for your options and step-by-step instructions.".format( + latest=latest_str, current=current_str + ) + ), + dismissible=True, + data={"currentVersion": current_str, "latestVersion": latest_str}, + actions=[ + NotificationAction( + id="update-guide", + label="View update guide", + kind="link", + href=UPDATE_DOCS_URL, + ), + ], + ) + ] diff --git a/engine/app/notifications/models.py b/engine/app/notifications/models.py new file mode 100644 index 00000000..448af2cc --- /dev/null +++ b/engine/app/notifications/models.py @@ -0,0 +1,75 @@ +""" +Notification framework models. + +A *notification* is a fact about the deployment that a human, an IaC pipeline, +or the Azure IPAM UI can read and act on. The shape is deliberately serializable +and self-describing so an API-only consumer gets everything it needs without the +UI, while the UI can override presentation by the stable ``id``. + +The same shape is intended to be produced by future *remote* notification +providers (a hosted feed fetched periodically). For security, remotely-authored +notifications will be constrained to informational / link actions only; only +local (in-code) detectors may emit actionable ``api`` remediations. +""" + +from typing import Literal, Optional + +from pydantic import BaseModel + +Severity = Literal["critical", "warning", "information"] +Audience = Literal["all", "admin"] +ActionKind = Literal["resolve", "link", "navigate"] + + +class NotificationAction(BaseModel): + """ + A single action a consumer can take for a notification. + + * ``resolve`` — a server-owned remediation. The client POSTs to ``href`` + (``method`` = POST) to trigger it; the backend holds all the + logic. ``causesRestart`` tells the UI to raise the restart + gate, and ``requiresAdmin`` (also enforced server-side) lets + the UI disable the control early for non-admins. + * ``link`` — an external reference (documentation / manual remediation). + * ``navigate`` — an in-app route (UI convenience only). + """ + + id: str + label: str + kind: ActionKind + + # resolve / link + method: Optional[str] = None + href: Optional[str] = None + causesRestart: Optional[bool] = None + requiresAdmin: Optional[bool] = None + + # navigate + to: Optional[str] = None + + +class Notification(BaseModel): + """A single, self-describing notification about the deployment.""" + + id: str + severity: Severity + audience: Audience = "all" + category: str + title: str + message: str + dismissible: bool = True + data: Optional[dict] = None + actions: list[NotificationAction] = [] + + +class NotificationList(BaseModel): + """Envelope returned by ``GET /api/notifications``.""" + + notifications: list[Notification] = [] + + +class NotificationActionResult(BaseModel): + """Acknowledgement that a notification remediation has been accepted.""" + + status: str + detail: str diff --git a/engine/app/routers/admin.py b/engine/app/routers/admin.py index c42e43b6..d52bbc32 100644 --- a/engine/app/routers/admin.py +++ b/engine/app/routers/admin.py @@ -1,38 +1,23 @@ -from fastapi.responses import PlainTextResponse -from fastapi.encoders import jsonable_encoder - -from fastapi import ( - APIRouter, - HTTPException, - Response, - status, - Depends, - Header, - Path -) - -from typing import List - import copy -import uuid - -from app.dependencies import ( - api_auth_checks, - get_admin, - get_tenant_id -) +from typing import List +from uuid import UUID, uuid4 -from app.models import * -from . import argquery +from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import PlainTextResponse +from app.dependencies import api_auth_checks, get_admin, get_tenant_id +from app.models import Admin, Subscription from app.routers.common.helper import ( + arg_query, cosmos_query, - cosmos_upsert, cosmos_replace, cosmos_retry, - arg_query + cosmos_upsert, ) +from . import argquery + router = APIRouter( prefix="/admin", tags=["admin"], @@ -41,7 +26,7 @@ async def new_admin_db(admin_list, exclusion_list, tenant_id): admin_data = { - "id": uuid.uuid4(), + "id": uuid4(), "type": "admin", "tenant_id": tenant_id, "admins": admin_list, @@ -113,7 +98,7 @@ async def create_admin( else: admin_data = copy.deepcopy(admin_query[0]) - target_admin = next((x for x in admin_data['admins'] if uuid.UUID(x['id']) == admin.id), None) + target_admin = next((x for x in admin_data['admins'] if UUID(x['id']) == admin.id), None) if target_admin: raise HTTPException(status_code=400, detail="User is already an admin.") @@ -166,7 +151,7 @@ async def update_admins( admin_data = copy.deepcopy(admin_query[0]) admin_data['admins'] = jsonable_encoder(admin_list) - + await cosmos_replace(admin_query[0], admin_data) return PlainTextResponse(status_code=status.HTTP_200_OK) @@ -177,7 +162,7 @@ async def update_admins( response_model = Admin, status_code = 200 ) -async def get_admins( +async def get_admin_by_id( objectId: UUID = Path(..., description="Azure AD ObjectID for the target user"), authorization: str = Header(None, description="Azure Bearer token"), tenant_id: str = Depends(get_tenant_id), @@ -194,7 +179,7 @@ async def get_admins( try: admins = copy.deepcopy(admin_query[0]) - except: + except Exception: raise HTTPException(status_code=400, detail="No admins found in database.") target_admin = next((x for x in admins['admins'] if x['id'] == str(objectId)), None) diff --git a/engine/app/routers/argquery.py b/engine/app/routers/argquery.py index 2bbdac98..7ad671e4 100644 --- a/engine/app/routers/argquery.py +++ b/engine/app/routers/argquery.py @@ -1,12 +1,13 @@ -RESERVATION = """ +RESERVATION = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' -| where isnotnull(tags["ipam-res-id"]) +| extend resv = coalesce(tags['X-IPAM-RES-ID'], tags['ipam-res-id']) +| where isnotnull(resv) | extend prefixes = properties.addressSpace.addressPrefixes -| project id, prefixes, resv = tags["ipam-res-id"] +| project id, prefixes, resv = tostring(resv) """ -# SUBSCRIPTION = """ +# SUBSCRIPTION = r""" # resourcecontainers # | where type =~ 'microsoft.resources/subscriptions' # | extend quotaId = properties.subscriptionPolicies.quotaId @@ -20,7 +21,7 @@ # | project name, id, type, subscription_id = subscriptionId, tenant_id = tenantId # """ -SUBSCRIPTION = """ +SUBSCRIPTION = r""" resourcecontainers | where type=~ 'microsoft.resources/subscriptions' | extend mgParent = properties.managementGroupAncestorsChain @@ -42,20 +43,20 @@ | project name, id, type, subscription_id = subscriptionId, mg_id = mgId, mg_name = mgDisplayName, tenant_id = tenantId """ -SPACE = """ +SPACE = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' | project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes = properties.addressSpace.addressPrefixes """ -BLOCK = """ +BLOCK = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' | project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes = properties.addressSpace.addressPrefixes """ # This version gets both IPv4 and IPv6 vNET/Subnet address spaces -# VNET = """ +# VNET = r""" # resources # | where type =~ 'Microsoft.Network/virtualNetworks' # | where subscriptionId !in~ {} @@ -87,7 +88,7 @@ # | project name, id, todynamic(prefixes), todynamic(subnets), peerings, resource_group, subscription_id, tenant_id, todynamic(resv) # """ -VNET = """ +VNET = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' | where subscriptionId !in~ {} @@ -123,7 +124,7 @@ """ # This version gets both the IPv4 and IPv6 Subnet address space -# SUBNET = """ +# SUBNET = r""" # resources # | where type =~ 'Microsoft.Network/virtualNetworks' # | where subscriptionId !in~ {} @@ -137,7 +138,7 @@ # | project name = subnet.name, id = subnet.id, prefix = subnet.properties.addressPrefix, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId,vnet_name = name, vnet_id = id, used = (iif(isnull(subnet_size), 0, subnet_size) + 5), type = todynamic(subnetType) # """ -SUBNET = """ +SUBNET = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' | where subscriptionId !in~ {} @@ -153,13 +154,13 @@ | project name = subnet.name, id = subnet.id, prefix = iff(isnotnull(subnetPrefixes), subnetPrefixes, pack_array(subnetPrefix)), resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId,vnet_name = name, vnet_id = id, used = (iif(isnull(subnet_size), 0, subnet_size) + 5), type = todynamic(subnetType) """ -# VWAN_HUBS = """ +# VWAN_HUBS = r""" # resources # | where type =~ 'microsoft.network/virtualhubs' # | project name, resource_group = resourceGroup, subscription_id = subscriptionId # """ -# VHUB = """ +# VHUB = r""" # resources # | where type =~ 'microsoft.network/virtualhubs' # | where subscriptionId !in~ {} @@ -174,7 +175,7 @@ # | project name, id, prefix, resource_group, subscription_id, tenant_id, metadata = pack('vwan_name', vwan_name, 'vwan_id', vwan_id) # """ -VHUB = """ +VHUB = r""" resources | where type =~ 'microsoft.network/virtualhubs' | where subscriptionId !in~ {} @@ -190,7 +191,7 @@ | project name, id, prefix, vwan_name, vwan_id, resource_group, subscription_id, tenant_id, resv """ -NET_BASIC = """ +NET_BASIC = r""" resources | where type =~ 'Microsoft.Network/virtualNetworks' | project name, id, resourceGroup, subscriptionId, tenantId, prefixes = properties.addressSpace.addressPrefixes @@ -204,7 +205,35 @@ | project name, id, resource_group = resourceGroup, subscription_id = subscriptionId, tenant_id = tenantId, prefixes """ -PRIVATE_ENDPOINT = """ +NETWORK_INTERFACE = r""" +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 = r""" resources | where type =~ 'microsoft.network/networkinterfaces' | where subscriptionId !in~ {} @@ -232,7 +261,7 @@ | project name = iff(notempty(name), name, pe_name), id = iff(notempty(id), id, pe_id), private_ip, resource_group = iff(notempty(resource_group), resource_group, pe_rg), subscription_id = iff(notempty(subscription_id), subscription_id, pe_sid), tenant_id = iff(notempty(tenant_id), tenant_id, pe_tid), vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -VIRTUAL_MACHINE = """ +VIRTUAL_MACHINE = r""" resources | where type =~ 'microsoft.compute/virtualmachines' | where subscriptionId !in~ {} @@ -268,7 +297,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -# VM_SCALE_SET = """ +# VM_SCALE_SET = r""" # ComputeResources # | where type =~ "microsoft.compute/virtualmachinescalesets/virtualmachines" # | where subscriptionId !in~ {} @@ -296,7 +325,7 @@ # | project name = strcat(vmss_name, '_', vmss_vm_num), id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata # """ -VM_SCALE_SET = """ +VM_SCALE_SET = r""" ComputeResources | where type =~ "microsoft.compute/virtualmachinescalesets/virtualmachines" | where subscriptionId !in~ {} @@ -325,10 +354,10 @@ | 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 = """ +FIREWALL_VNET = r""" resources | where type =~ 'Microsoft.Network/azureFirewalls' | where subscriptionId !in~ {} @@ -356,7 +385,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -# FIREWALL_VHUB = """ +# FIREWALL_VHUB = r""" # resources # | where type =~ 'Microsoft.Network/azureFirewalls' # | where subscriptionId !in~ {} @@ -372,7 +401,7 @@ # | project-away id1 # """ -BASTION = """ +BASTION = r""" resources | where type =~ 'Microsoft.Network/bastionHosts' | where subscriptionId !in~ {} @@ -399,7 +428,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -VNET_GATEWAY = """ +VNET_GATEWAY = r""" resources | where type =~ 'microsoft.Network/virtualNetworkGateways' | where subscriptionId !in~ {} @@ -435,7 +464,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata = iff(tolower(type) == 'vpn', metadata_vpn, metadata_exr) """ -APP_GATEWAY = """ +APP_GATEWAY = r""" resources | where type =~ 'Microsoft.Network/applicationGateways' | where subscriptionId !in~ {} @@ -471,7 +500,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -APIM = """ +APIM = r""" resources | where type =~ 'Microsoft.ApiManagement/service' | where subscriptionId !in~ {} @@ -492,7 +521,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -LB = """ +LB = r""" resources | where type =~ 'microsoft.Network/LoadBalancers' | where subscriptionId !in~ {} @@ -519,7 +548,7 @@ | project name, id, private_ip, resource_group, subscription_id, tenant_id, vnet_name, vnet_id, subnet_name, subnet_id, metadata """ -VHUB_ENDPOINT = """ +VHUB_ENDPOINT = r""" resources | where subscriptionId !in~ {} | where isnotnull(properties.virtualHub.id) diff --git a/engine/app/routers/azure.py b/engine/app/routers/azure.py index 7b1d6f66..85185495 100644 --- a/engine/app/routers/azure.py +++ b/engine/app/routers/azure.py @@ -1,49 +1,39 @@ -from fastapi import ( - APIRouter, - HTTPException, - Depends, - Header -) +import asyncio +import copy +import re +import time +from typing import List from azure.core.exceptions import ClientAuthenticationError, HttpResponseError from azure.mgmt.compute.aio import ComputeManagementClient -from azure.mgmt.network.aio import NetworkManagementClient from azure.mgmt.datafactory.aio import DataFactoryManagementClient -from azure.mgmt.resourcegraph.aio import ResourceGraphClient +from azure.mgmt.network.aio import NetworkManagementClient from azure.mgmt.resource.subscriptions.aio import SubscriptionClient -from azure.mgmt.resourcegraph.models import QueryRequest, QueryRequestOptions, ResultFormat - -from typing import List - -import re -import copy -import time -import asyncio -from netaddr import IPSet, IPNetwork - -from app.dependencies import ( - api_auth_checks, - get_admin, - get_tenant_id +from azure.mgmt.resourcegraph.aio import ResourceGraphClient +from azure.mgmt.resourcegraph.models import ( + QueryRequest, + QueryRequestOptions, + ResultFormat, ) +from fastapi import APIRouter, Depends, Header, HTTPException +from netaddr import IPNetwork, IPSet -from app.models import * -from . import argquery - +from app.dependencies import api_auth_checks, get_admin, get_tenant_id +from app.globals import globals +from app.logs.logs import ipam_logger as logger +from app.models import VWanHub from app.routers.common.helper import ( - get_client_credentials, - get_obo_credentials, + arg_query, cosmos_query, cosmos_replace, cosmos_retry, - arg_query, + get_client_credentials, + get_obo_credentials, + subnet_fixup, vnet_fixup, - subnet_fixup ) -from app.globals import globals - -from app.logs.logs import ipam_logger as logger +from . import argquery router = APIRouter( prefix="/azure", @@ -55,7 +45,7 @@ def str_to_list(input): try: scrubbed = re.sub(r"\s+", "", input, flags = re.UNICODE) split = scrubbed.split(",") - except: + except Exception: return [] return split @@ -409,7 +399,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 +412,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 +807,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 +853,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) @@ -858,8 +865,15 @@ async def multi( error_msg = "Error updating reservation status!" ) async def match_resv_to_vnets(): + # Reservation/vNet reconciliation writes to Cosmos, so it must only run on the + # production slot. This single guard covers both entry points that call it: + # the App Service scheduler and the Functions timer trigger ('sentinel'). + if not globals.IS_PRODUCTION_SLOT: + logger.info("Skipping reservation reconciliation in non-production slot '%s'.", globals.SLOT_NAME) + return + net_list = await get_network(None, globals.TENANT_ID, True) - stale_resv = list(i for j in list(str_to_list(x['resv']) for x in net_list if x['resv'] != None) for i in j) + stale_resv = list(i for j in list(str_to_list(x['resv']) for x in net_list if x['resv'] is not None) for i in j) space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space'", globals.TENANT_ID) @@ -912,20 +926,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/common/helper.py b/engine/app/routers/common/helper.py index 726ad9dc..089f6f51 100644 --- a/engine/app/routers/common/helper.py +++ b/engine/app/routers/common/helper.py @@ -1,45 +1,65 @@ -from fastapi import HTTPException - -from azure.identity.aio import OnBehalfOfCredential, ManagedIdentityCredential, ClientSecretCredential - -from azure.core import MatchConditions -from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ServiceRequestError - -from azure.mgmt.resourcegraph.aio import ResourceGraphClient -from azure.mgmt.resourcegraph.models import QueryRequest, QueryRequestOptions, ResultFormat -from azure.mgmt.managementgroups.aio import ManagementGroupsAPI +from functools import wraps -from azure.cosmos.aio import CosmosClient import azure.cosmos.exceptions as exceptions - import jwt +from azure.core import MatchConditions +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ServiceRequestError, +) +from azure.cosmos.aio import CosmosClient +from azure.identity.aio import ( + ClientSecretCredential, + ManagedIdentityCredential, + OnBehalfOfCredential, +) +from azure.mgmt.managementgroups.aio import ManagementGroupsMgmtClient +from azure.mgmt.resourcegraph.aio import ResourceGraphClient +from azure.mgmt.resourcegraph.models import ( + QueryRequest, + QueryRequestOptions, + ResultFormat, +) +from fastapi import HTTPException from netaddr import IPNetwork -from functools import wraps from app.globals import globals -managed_identity_credential = ManagedIdentityCredential( - client_id = globals.MANAGED_IDENTITY_ID -) +_cosmos_client = None -cosmos_client = CosmosClient( - url=globals.COSMOS_URL, - credential=(globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential), - transport=globals.SHARED_TRANSPORT -) +def get_cosmos_client(): + # Created lazily so the shared aiohttp transport is built within a running event loop. Newer + # aiohttp requires a running loop when its ClientSession/TCPConnector are created; building the + # client (and thus accessing globals.SHARED_TRANSPORT) at import time raised "no running event + # loop" and crash-looped the engine on startup. + global _cosmos_client + + if _cosmos_client is None: + managed_identity_credential = ManagedIdentityCredential( + client_id = globals.MANAGED_IDENTITY_ID + ) + + _cosmos_client = CosmosClient( + url=globals.COSMOS_URL, + credential=(globals.COSMOS_KEY if globals.COSMOS_KEY else managed_identity_credential), + transport=globals.SHARED_TRANSPORT + ) + + return _cosmos_client def valid_ipv4(addr): try: - ip_net = IPNetwork(addr, version=4) - except: + IPNetwork(addr, version=4) + except Exception: return False return True def valid_ipv6(addr): try: - ip_net = IPNetwork(addr, version=6) - except: + IPNetwork(addr, version=6) + except Exception: return False return True @@ -144,7 +164,7 @@ async def get_mgmt_group_name(tenant_id): """DOCSTRING""" client_creds = await get_client_credentials() - mgmt_group_api = ManagementGroupsAPI(client_creds) + mgmt_group_api = ManagementGroupsMgmtClient(client_creds) try: result = await mgmt_group_api.management_groups.get(tenant_id) @@ -163,7 +183,7 @@ async def cosmos_query(query: str, tenant_id: str): # cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) database_name = globals.DATABASE_NAME - database = cosmos_client.get_database_client(database_name) + database = get_cosmos_client().get_database_client(database_name) container_name = globals.CONTAINER_NAME container = database.get_container_client(container_name) @@ -186,7 +206,7 @@ async def cosmos_upsert(data): # cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) database_name = globals.DATABASE_NAME - database = cosmos_client.get_database_client(database_name) + database = get_cosmos_client().get_database_client(database_name) container_name = globals.CONTAINER_NAME container = database.get_container_client(container_name) @@ -208,7 +228,7 @@ async def cosmos_replace(old, new): # cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) database_name = globals.DATABASE_NAME - database = cosmos_client.get_database_client(database_name) + database = get_cosmos_client().get_database_client(database_name) container_name = globals.CONTAINER_NAME container = database.get_container_client(container_name) @@ -235,7 +255,7 @@ async def cosmos_delete(item, tenant_id: str): # cosmos_client = CosmosClient(globals.COSMOS_URL, credential=globals.COSMOS_KEY) database_name = globals.DATABASE_NAME - database = cosmos_client.get_database_client(database_name) + database = get_cosmos_client().get_database_client(database_name) container_name = globals.CONTAINER_NAME container = database.get_container_client(container_name) @@ -270,7 +290,7 @@ async def func_with_retries(*args, **kwargs): if _tries == 0: raise HTTPException(status_code=500, detail=error_msg) - + return func_with_retries return cosmos_retry_decorator diff --git a/engine/app/routers/health.py b/engine/app/routers/health.py new file mode 100644 index 00000000..d0eac427 --- /dev/null +++ b/engine/app/routers/health.py @@ -0,0 +1,137 @@ +import asyncio + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse + +from app.dependencies import validate_token +from app.globals import globals +from app.routers.common.helper import arg_query_helper, get_client_credentials +from app.schema.control import read_schema_doc + +router = APIRouter( + prefix="/health", + tags=["health"] +) + +# Bound each dependency probe so a hung dependency can't stall the whole report. +HEALTH_CHECK_TIMEOUT = 10 + +# Settings/secrets that must be present for the engine to function. A missing +# value usually means an unresolved Key Vault reference or a misconfigured +# deployment. Only NAMES are ever reported, never values. +REQUIRED_SETTINGS = ( + "COSMOS_URL", + "DATABASE_NAME", + "CONTAINER_NAME", + "TENANT_ID", + "CLIENT_ID", + "CLIENT_SECRET", +) + + +async def _check_config(): + """Verify required settings resolved (no network).""" + + missing = [name for name in REQUIRED_SETTINGS if not getattr(globals, name, None)] + + # Cosmos auth needs either a managed identity or an account key. + if not globals.MANAGED_IDENTITY_ID and not globals.COSMOS_KEY: + missing.append("MANAGED_IDENTITY_ID|COSMOS_KEY") + + if missing: + raise RuntimeError("Missing required settings: " + ", ".join(missing)) + + +async def _check_cosmos(): + """Point-read the schema document: proves reachability + identity + read access.""" + + await read_schema_doc() + + +async def _check_arm(): + """Minimal Resource Graph query: proves engine SP token + ARM reachability + RBAC.""" + + creds = await get_client_credentials() + + try: + await arg_query_helper(creds, "Resources | project id | limit 1") + finally: + await creds.close() + + +async def _run_check(check): + """Run one dependency check, bounded by a timeout, and normalize the result.""" + + try: + await asyncio.wait_for(check(), timeout=HEALTH_CHECK_TIMEOUT) + + return {"ok": True} + except asyncio.TimeoutError: + return {"ok": False, "detail": f"Timed out after {HEALTH_CHECK_TIMEOUT}s."} + except HTTPException as e: + return {"ok": False, "detail": str(e.detail)} + except Exception as e: + return {"ok": False, "detail": str(e)} + + +async def _evaluate_schema(): + """ + Report code-vs-data schema compatibility as its own check (distinct from + ``cosmos`` so a version mismatch isn't misread as a connectivity failure). + + Mode-aware: "convergence pending" (data behind this build) is a real problem + in production but is EXPECTED on a staging slot, which never converges until + after the swap. Always carries the version numbers in ``detail``. + """ + + try: + doc = await asyncio.wait_for(read_schema_doc(), timeout=HEALTH_CHECK_TIMEOUT) + except Exception as e: + return {"ok": False, "detail": f"Unable to read schema version (Cosmos error): {e}"} + + current = doc["current"] if doc else 0 + min_readable = doc["minReadable"] if doc else 0 + code = globals.SCHEMA_VERSION + + versions = f"code v{code}, current v{current}, minReadable v{min_readable}" + + if code < min_readable: + return {"ok": False, "detail": f"Incompatible — this build is older than the data ({versions})."} + + if current < code: + if globals.IS_PRODUCTION_SLOT: + return {"ok": False, "detail": f"Convergence pending — data is behind this build ({versions})."} + + return {"ok": True, "detail": f"Convergence pending, expected pre-swap in staging ({versions})."} + + return {"ok": True, "detail": versions} + + +@router.get( + "", + summary="Azure IPAM Dependency Health Check", + dependencies=[Depends(validate_token)], + status_code=200 +) +async def get_health(): + """ + Live checklist of the engine's dependencies and internal state (config, + Cosmos, ARM, schema compatibility). + + Always returns HTTP 200 when the request itself is authenticated and served; + the ``ok`` flag and per-check results convey whether anything is wrong. + Requires a valid token but not admin, and is intentionally Cosmos-independent + to run (so it can still report a Cosmos outage). + """ + + dep_names = ("config", "cosmos", "arm") + dep_checks = (_check_config, _check_cosmos, _check_arm) + + dep_results = await asyncio.gather(*(_run_check(check) for check in dep_checks)) + + report = dict(zip(dep_names, dep_results)) + report["schema"] = await _evaluate_schema() + + overall = all(result["ok"] for result in report.values()) + + return JSONResponse({"ok": overall, "checks": report}, status_code=200) diff --git a/engine/app/routers/internal.py b/engine/app/routers/internal.py index bf26f73e..42b955b7 100644 --- a/engine/app/routers/internal.py +++ b/engine/app/routers/internal.py @@ -1,36 +1,22 @@ -from fastapi import ( - APIRouter, - Depends, - Header -) - import asyncio -from netaddr import IPNetwork - -from app.dependencies import ( - api_auth_checks, - get_admin, - get_tenant_id -) - -from app.models import * -from app.routers.space import ( - get_spaces -) +from fastapi import APIRouter, Depends, Header +from netaddr import IPNetwork +from app.dependencies import api_auth_checks, get_admin, get_tenant_id from app.routers.azure import ( - get_vnet, + apim, + appgw, + bastion, + fwvnet, get_subnet, + get_vnet, pe, vm, vmss, - fwvnet, - bastion, vnetgw, - appgw, - apim ) +from app.routers.space import get_spaces router = APIRouter( prefix="/internal", diff --git a/engine/app/routers/notifications.py b/engine/app/routers/notifications.py new file mode 100644 index 00000000..3b3d884d --- /dev/null +++ b/engine/app/routers/notifications.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException +from fastapi.responses import JSONResponse + +from app.dependencies import api_auth_checks, get_admin +from app.notifications import get_notifications, get_resolver +from app.notifications.models import NotificationActionResult + +router = APIRouter( + prefix="/notifications", + tags=["notifications"], + dependencies=[Depends(api_auth_checks)] +) + + +@router.get( + "", + summary="Get Azure IPAM Notifications", + status_code=200 +) +async def list_notifications(): + """ + Active notifications about this Azure IPAM deployment. + + Each notification is self-describing: a stable ``id``, ``severity``, human + ``title``/``message``, structured ``data``, and zero or more ``actions``. + An ``api`` action carries the ``method``/``endpoint``/``body`` to remediate + it (some require an admin); a ``link`` action points at documentation. + + Visible to any authenticated user so a non-admin can surface an issue to an + admin; the underlying remediation endpoints enforce their own authorization. + """ + + result = await get_notifications() + + return JSONResponse(result.model_dump(exclude_none=True)) + + +@router.post( + "/{notification_id}/resolve", + summary="Resolve a Notification", + response_model=NotificationActionResult, + status_code=202 +) +async def resolve_notification( + notification_id: str, + background_tasks: BackgroundTasks, + authorization: str = Header(None, description="Azure Bearer token"), + is_admin: str = Depends(get_admin) +): + """ + Invoke the server-owned remediation for a notification. + + The backend holds all remediation logic; the caller only asks for the fix to + be applied (the notification's ``resolve`` action). Requires an admin. + + Returns 404 when the notification has no remediation defined, and 409 when the + notification is not currently active (e.g. it has already been resolved) — so + a stale or duplicate request never triggers work. + """ + + if not is_admin: + raise HTTPException(status_code=403, detail="API restricted to admins.") + + resolver = get_resolver(notification_id) + + if resolver is None: + raise HTTPException(status_code=404, detail="No remediation is available for this notification.") + + # The detector is the single source of truth for whether a notification is + # active. Only remediate something that is actually being surfaced right now. + active = await get_notifications() + + if not any(item.id == notification_id for item in active.notifications): + raise HTTPException( + status_code=409, + detail="This notification is not currently active; it may already have been resolved." + ) + + return await resolver(background_tasks) diff --git a/engine/app/routers/space.py b/engine/app/routers/space.py index 328db197..a7d8d714 100644 --- a/engine/app/routers/space.py +++ b/engine/app/routers/space.py @@ -1,59 +1,75 @@ -from fastapi.responses import PlainTextResponse -from fastapi.encoders import jsonable_encoder - -from fastapi import ( - APIRouter, - HTTPException, - status, - Depends, - Header, - Query, - Path -) - -from typing import Optional, List, Union - +import copy import re -import jwt import time import uuid -import copy -import shortuuid -import jsonpatch -from netaddr import IPSet, IPNetwork, IPAddress - -from app.dependencies import ( - api_auth_checks, - get_admin, - get_tenant_id -) - -from app.models import * +from typing import List, Optional, Union +import jsonpatch +import jwt +import shortuuid +from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import PlainTextResponse +from netaddr import IPAddress, IPNetwork, IPSet + +from app.dependencies import api_auth_checks, get_admin, get_tenant_id +from app.models import ( + Block, + BlockBasic, + BlockBasicUtil, + BlockCIDRReq, + BlockExpand, + BlockExpandUtil, + BlockReq, + BlockUpdate, + BlockUtil, + DeleteExtEndpointsReq, + DeleteResvReq, + ExtEndpoint, + ExtEndpointReq, + ExtEndpointUpdate, + ExtNet, + ExtNetExpand, + ExtNetReq, + ExtNetUpdate, + ExtSubnet, + ExtSubnetExpand, + ExtSubnetReq, + ExtSubnetUpdate, + Network, + NetworkExpand, + ReservationExpand, + Space, + SpaceBasic, + SpaceBasicUtil, + SpaceCIDRReq, + SpaceExpand, + SpaceExpandUtil, + SpaceReq, + SpaceUpdate, + SpaceUtil, + VNet, + VNetsUpdate, +) +from app.routers.azure import get_network from app.routers.common.helper import ( - get_username_from_jwt, + cosmos_delete, cosmos_query, - cosmos_upsert, cosmos_replace, - cosmos_delete, - cosmos_retry -) - -from app.routers.azure import ( - get_network + cosmos_retry, + cosmos_upsert, + get_username_from_jwt, ) -from app.logs.logs import ipam_logger as logger - -SPACE_NAME_REGEX = "^(?![\._-])([a-zA-Z0-9\._-]){1,64}(?, "minReadable": } + +* ``current`` — the schema version the data has been converged to. +* ``minReadable`` — the oldest code schema version that can still safely read the + data. Additive convergence leaves this untouched (so older + code, e.g. after a swap-back, keeps working); only a breaking + convergence raises it. + +The document lives in the same container as everything else, partitioned by +``tenant_id``, with a dedicated ``type`` ("schema") so type-filtered queries +never pick it up. +""" + +from datetime import datetime, timezone + +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from app.globals import globals +from app.routers.common.helper import get_cosmos_client + +SCHEMA_DOC_ID = "schema" + + +def _now(): + return datetime.now(timezone.utc) + + +def _iso(value): + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _container(): + client = get_cosmos_client() + database = client.get_database_client(globals.DATABASE_NAME) + + return database.get_container_client(globals.CONTAINER_NAME) + + +async def read_schema_doc(): + """ + Return ``{"current": int, "minReadable": int}`` or ``None`` when the database + is unversioned (pre-v4, no schema document yet). + """ + + try: + doc = await _container().read_item(SCHEMA_DOC_ID, partition_key=globals.TENANT_ID) + + return { + "current": int(doc.get("current", 0)), + "minReadable": int(doc.get("minReadable", 0)), + } + except CosmosResourceNotFoundError: + return None + + +async def write_schema_doc(current, min_readable): + """Upsert the schema control document.""" + + doc = { + "id": SCHEMA_DOC_ID, + "type": "schema", + "tenant_id": globals.TENANT_ID, + "current": int(current), + "minReadable": int(min_readable), + "appVersion": globals.IPAM_VERSION, + "updatedOn": _iso(_now()), + } + + await _container().upsert_item(doc) diff --git a/engine/app/schema/convergence.py b/engine/app/schema/convergence.py new file mode 100644 index 00000000..a15655e5 --- /dev/null +++ b/engine/app/schema/convergence.py @@ -0,0 +1,110 @@ +""" +Schema compatibility gate and production-slot convergence. + +Two entry points, both called from the engine's startup ``lifespan``: + +* ``check_compatibility()`` runs on EVERY boot, in any slot. It compares this + build's schema version (``globals.SCHEMA_VERSION``) against the database's + compatibility floor and decides whether this code may serve at all. This is + what makes an accidentally-old build fail gracefully instead of misreading a + newer database. + +* ``run_convergence()`` runs ONLY on the production slot, after a swap. It walks + the data forward to this build's schema version and updates the schema control + document. Staging never converges, so the shared database is never mutated + while the previous version is still live in production. +""" + +from dataclasses import dataclass + +from app.globals import globals +from app.logs.logs import ipam_logger as logger +from app.schema.control import read_schema_doc, write_schema_doc +from app.schema.steps import STEPS + + +@dataclass(frozen=True) +class CompatibilityResult: + compatible: bool + code_schema: int + db_current: int + db_min_readable: int + detail: str + + +async def check_compatibility(): + """ + Compare this build's schema version to the database's compatibility floor. + + * code_schema >= db_current -> up to date (or DB behind; will converge) + * db_min_readable <= code < db_current -> DB is newer but only ADDITIVELY; safe + (this preserves swap-back / rollback) + * code_schema < db_min_readable -> a breaking change happened that this + build cannot read -> NOT compatible + """ + + doc = await read_schema_doc() + current = doc["current"] if doc else 0 + min_readable = doc["minReadable"] if doc else 0 + code = globals.SCHEMA_VERSION + + compatible = code >= min_readable + + if compatible: + detail = f"Code schema v{code} is compatible with database (current v{current}, minReadable v{min_readable})." + else: + detail = ( + f"Code schema v{code} is older than the database's minimum readable schema v{min_readable}. " + "This build cannot safely read the current data. Deploy a newer build." + ) + logger.error(detail) + + return CompatibilityResult( + compatible=compatible, + code_schema=code, + db_current=current, + db_min_readable=min_readable, + detail=detail, + ) + + +async def run_convergence(): + """ + Bring the data up to this build's schema version. MUST only be called on the + production slot. Idempotent: applies only steps newer than the recorded + ``current`` and is a no-op when already up to date. + """ + + doc = await read_schema_doc() + current = doc["current"] if doc else 0 + min_readable = doc["minReadable"] if doc else 0 + target = globals.SCHEMA_VERSION + + if target <= current: + logger.info("Database schema already at v%s; no convergence needed.", current) + return + + for step in STEPS: + if step.version <= current: + continue + + if step.version > target: + break + + logger.warning( + "Converging schema to v%s (%s, %s)...", + step.version, + step.name, + "breaking" if step.breaking else "additive", + ) + + await step.apply() + + current = step.version + + if step.breaking: + min_readable = step.version + + await write_schema_doc(current, min_readable) + + logger.warning("Converged to schema v%s.", step.version) diff --git a/engine/app/schema/steps.py b/engine/app/schema/steps.py new file mode 100644 index 00000000..feb5ef5c --- /dev/null +++ b/engine/app/schema/steps.py @@ -0,0 +1,252 @@ +""" +Ordered registry of convergence steps. + +A *convergence* step brings the data from one schema version to the next. Steps +run **only on the production slot** (see ``app.schema.convergence``), after a +swap, so they never mutate the shared database while the previous version is +still live in production. Staging never converges. + +Each step declares whether it is **breaking**: + +* ``breaking = False`` (additive) — only adds fields/documents. Older code (e.g. + the previous release still in the other slot, or after a swap-back) keeps + working, so ``minReadable`` is NOT raised. +* ``breaking = True`` — removes/reshapes data such that older code can no longer + read it. Applying it raises ``minReadable`` to this step's version, which makes + the startup compatibility gate refuse to run any code older than this step. + +Version 1 is the **baseline**: it brings an unversioned (pre-v4) database up to +the v4 shape, folding in every fixup that historically lived in ``upgrade_db()``. +Pre-v4 builds are not slot-aware, so this one is allowed to be destructive; it is +marked breaking so genuinely ancient code can't run against a v4 database. + +Every step's ``apply`` MUST be idempotent (guard with ``NOT IS_DEFINED(...)`` / +read-before-write) so a re-run is a harmless no-op. +""" + +import copy +import uuid +from dataclasses import dataclass +from typing import Awaitable, Callable + +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from fastapi.encoders import jsonable_encoder + +from app.globals import globals +from app.logs.logs import ipam_logger as logger +from app.routers.common.helper import cosmos_query, cosmos_replace, cosmos_upsert +from app.schema.control import _container + + +@dataclass(frozen=True) +class ConvergenceStep: + version: int + name: str + breaking: bool + apply: Callable[[], Awaitable[None]] + + +async def _baseline_v4(): + """ + Pre-v4 -> v4 catch-up. Consolidates every historical ``upgrade_db()`` fixup + so that a customer jumping from a very old version lands on the current shape + in one shot. All operations are guarded/idempotent, so this is also a no-op + against a brand-new (empty) database. + """ + + container = _container() + tenant_id = globals.TENANT_ID + + # Legacy "spaces" singleton -> individual space documents. + try: + spaces_query = await container.read_item("spaces", partition_key="spaces") + + for space in spaces_query['spaces']: + if 'vnets' in space: + del space['vnets'] + + new_space = { + "id": uuid.uuid4(), + "type": "space", + "tenant_id": tenant_id, + **space + } + + await cosmos_upsert(jsonable_encoder(new_space)) + + await container.delete_item("spaces", partition_key="spaces") + + logger.warning('Spaces database conversion complete!') + except CosmosResourceNotFoundError: + logger.info('No existing spaces to convert...') + + # Legacy "users" singleton -> individual user documents. + try: + users_query = await container.read_item("users", partition_key="users") + + for user in users_query['users']: + new_user = { + "id": uuid.uuid4(), + "type": "user", + "tenant_id": tenant_id, + "data": user + } + + await cosmos_upsert(jsonable_encoder(new_user)) + + await container.delete_item("users", partition_key="users") + + logger.warning('Users database conversion complete!') + except CosmosResourceNotFoundError: + logger.info('No existing users to convert...') + + # Legacy "admins" singleton -> single admin document with exclusions. + try: + admins_query = await container.read_item("admins", partition_key="admins") + + admin_data = { + "id": uuid.uuid4(), + "type": "admin", + "tenant_id": tenant_id, + "admins": admins_query['admins'], + "exclusions": [] + } + + await cosmos_upsert(jsonable_encoder(admin_data)) + + await container.delete_item("admins", partition_key="admins") + + logger.warning('Admins database conversion complete!') + except CosmosResourceNotFoundError: + logger.info('No existing admins to convert...') + + # Backfill user objects missing 'darkMode' / 'views'. + user_fixup_query = await cosmos_query("SELECT * FROM c WHERE (c.type = 'user' AND (NOT IS_DEFINED(c['data']['darkMode']) OR NOT IS_DEFINED(c['data']['views'])))", tenant_id) + + if user_fixup_query: + for user in user_fixup_query: + user_data = copy.deepcopy(user) + + if 'darkMode' not in user_data['data']: + user_data['data']['darkMode'] = False + + if 'views' not in user_data['data']: + user_data['data']['views'] = {} + + await cosmos_replace(user, user_data) + + logger.warning('User object patching complete!') + else: + logger.info("No existing user objects to patch...") + + # Backfill admin entries missing a 'type' discriminator. + admin_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN admin IN c.admins WHERE (c.type = 'admin' AND NOT IS_DEFINED(admin.type))", tenant_id) + + if admin_fixup_query: + admin_data = copy.deepcopy(admin_fixup_query[0]) + + for i, admin in enumerate(admin_data['admins']): + if 'type' not in admin: + admin_data['admins'][i] = { + "type": "User", + "name": admin['name'], + "email": admin['email'], + "id": admin['id'] + } + + await cosmos_replace(admin_fixup_query[0], admin_data) + + logger.warning('Admin object patching complete!') + else: + logger.info("No existing admin objects to patch...") + + # Backfill reservations missing the settled-on/by fields. + resv_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks JOIN resv in block.resv WHERE (c.type = 'space' AND NOT IS_DEFINED(resv.settledOn))", tenant_id) + + if resv_fixup_query: + for space in resv_fixup_query: + space_data = copy.deepcopy(space) + + for block in space_data['blocks']: + for i, resv in enumerate(block['resv']): + if 'settledOn' not in resv: + block['resv'][i] = { + "id": resv['id'], + "cidr": resv['cidr'], + "desc": resv['desc'] if 'desc' in resv else None, + "createdOn": resv['createdOn'], + "createdBy": resv['userId'], + "settledOn": None, + "settledBy": None, + "status": resv['status'] + } + + await cosmos_replace(space, space_data) + + logger.warning('Reservation patching complete!') + else: + logger.info("No existing reservations to patch...") + + # Backfill blocks missing the 'externals' array. + external_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks WHERE (c.type = 'space' AND NOT IS_DEFINED(block.externals))", tenant_id) + + if external_fixup_query: + for space in external_fixup_query: + space_data = copy.deepcopy(space) + + new_blocks = [] + + for block in space_data['blocks']: + new_block = { + "name": block['name'], + "cidr": block['cidr'], + "vnets": block['vnets'], + "externals": [], + "resv": block['resv'] + } + + new_blocks.append(new_block) + + space_data['blocks'] = new_blocks + + await cosmos_replace(space, space_data) + + logger.warning('External networks patching complete!') + else: + logger.info("No existing external networks to patch...") + + # Backfill externals missing the 'subnets' array. + subnet_fixup_query = await cosmos_query("SELECT DISTINCT VALUE c FROM c JOIN block IN c.blocks JOIN ext in block.externals WHERE (c.type = 'space' AND NOT IS_DEFINED(ext.subnets))", tenant_id) + + if subnet_fixup_query: + for space in subnet_fixup_query: + space_data = copy.deepcopy(space) + + for block in space_data['blocks']: + new_externals = [] + + for external in block['externals']: + new_external = { + "name": external['name'], + "desc": external['desc'], + "cidr": external['cidr'], + "subnets": [] + } + + new_externals.append(new_external) + + block['externals'] = new_externals + + await cosmos_replace(space, space_data) + + logger.warning('External subnet patching complete!') + else: + logger.info("No existing external subnets to patch...") + + +# Ordered list of convergence steps. Append new steps with the next version +# number; never renumber or mutate an already-released step. Keep the highest +# version here in sync with "schema" in version.json. +STEPS = [ + ConvergenceStep(version=1, name="v4-baseline", breaking=True, apply=_baseline_v4), +] diff --git a/engine/app/version.json b/engine/app/version.json index 42192be1..1a6d59c7 100644 --- a/engine/app/version.json +++ b/engine/app/version.json @@ -1,4 +1,5 @@ { "app": "3.6.0", + "schema": 1, "python": "3.11" } diff --git a/engine/function_app.py b/engine/function_app.py index b055bda4..fdc8b0f4 100644 --- a/engine/function_app.py +++ b/engine/function_app.py @@ -11,20 +11,25 @@ import azure.functions as func -from app.main import app as ipam from app.logs.logs import ipam_logger as logger +from app.main import app as ipam from app.routers.azure import match_resv_to_vnets 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/init.sh b/engine/init.sh index ffc351f7..5ad5943e 100644 --- a/engine/init.sh +++ b/engine/init.sh @@ -3,4 +3,4 @@ PORT=$1 # Start the Uvicorn Server -uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} +exec uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} diff --git a/engine/pyproject.toml b/engine/pyproject.toml new file mode 100644 index 00000000..63841908 --- /dev/null +++ b/engine/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "azure-ipam-engine" +requires-python = ">=3.11" + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E4", # pycodestyle import errors + "E7", # pycodestyle statement errors + "E9", # pycodestyle runtime errors + "F", # pyflakes + "I", # isort + "W291", # trailing whitespace + "W293", # blank line with whitespace + "W605", # invalid escape sequence +] 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..1a727d4c 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,6 +1,5 @@ -fastapi[all]>=0.103.0 -pydantic[email]>=2.3.0 -msal +fastapi[all] +pydantic[email] pyjwt cryptography requests @@ -10,17 +9,17 @@ 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 +azure-mgmt-web 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..bea4c87b 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 @@ -13,4 +13,4 @@ eval $(printenv | sed -n "s/^\([^=]\+\)=\(.*\)$/export \1=\2/p" | sed 's/"/\\\"/ /usr/sbin/sshd # Start the Uvicorn Server -python -m uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} +exec python -m uvicorn "app.main:app" --reload --host "0.0.0.0" --port ${PORT} diff --git a/lb/Dockerfile b/lb/Dockerfile index 2ef4c54b..20338238 100644 --- a/lb/Dockerfile +++ b/lb/Dockerfile @@ -1,6 +1,14 @@ ARG BASE_IMAGE=nginx:alpine FROM $BASE_IMAGE +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Load Balancer" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Create File Links RUN ln -sf /dev/stdout /var/log/nginx/access.log RUN ln -sf /dev/stderr /var/log/nginx/error.log @@ -9,4 +17,4 @@ RUN ln -sf /dev/stderr /var/log/nginx/error.log COPY nginx.conf /etc/nginx/nginx.conf # Run script uses standard ways to run the application -CMD nginx -g "daemon off;" +CMD ["nginx", "-g", "daemon off;"] diff --git a/lb/Dockerfile.deb b/lb/Dockerfile.deb index 8cb034c5..e99118c0 100644 --- a/lb/Dockerfile.deb +++ b/lb/Dockerfile.deb @@ -9,4 +9,4 @@ RUN ln -sf /dev/stderr /var/log/nginx/error.log COPY nginx.conf /etc/nginx/nginx.conf # Run script uses standard ways to run the application -CMD nginx -g "daemon off;" +CMD ["nginx", "-g", "daemon off;"] diff --git a/lb/Dockerfile.dev b/lb/Dockerfile.dev index b633cc9c..f900727d 100644 --- a/lb/Dockerfile.dev +++ b/lb/Dockerfile.dev @@ -9,4 +9,4 @@ RUN ln -sf /dev/stderr /var/log/nginx/error.log COPY nginx.dev.conf /etc/nginx/nginx.conf # Run script uses standard ways to run the application -CMD nginx -g "daemon off;" +CMD ["nginx", "-g", "daemon off;"] diff --git a/lb/Dockerfile.rhel b/lb/Dockerfile.rhel index 529ea07a..b37ef12d 100644 --- a/lb/Dockerfile.rhel +++ b/lb/Dockerfile.rhel @@ -1,6 +1,14 @@ -ARG BASE_IMAGE=registry.access.redhat.com/ubi8/nginx-124 +ARG BASE_IMAGE=registry.access.redhat.com/ubi9/nginx-126 FROM $BASE_IMAGE +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM Load Balancer" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Create File Links RUN ln -sf /dev/stdout /var/log/nginx/access.log RUN ln -sf /dev/stderr /var/log/nginx/error.log @@ -9,4 +17,4 @@ RUN ln -sf /dev/stderr /var/log/nginx/error.log COPY nginx.conf /etc/nginx/nginx.conf # Run script uses standard ways to run the application -CMD nginx -g "daemon off;" +CMD ["nginx", "-g", "daemon off;"] diff --git a/migrate/migrate.ps1 b/migrate/migrate.ps1 index 2513c509..2538b0ea 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 ` @@ -1213,8 +1239,8 @@ Function Build-ContainerImage { Extension = 'rhel' Port = 8080 Images = @{ - Build = 'registry.access.redhat.com/ubi8/nodejs-22' - Serve = 'registry.access.redhat.com/ubi8/python-311' + Build = 'registry.access.redhat.com/ubi9/nodejs-22' + Serve = 'registry.access.redhat.com/ubi9/python-311' } } } diff --git a/migrate/modules/appService.bicep b/migrate/modules/appService.bicep index c4da561a..ad1a209d 100644 --- a/migrate/modules/appService.bicep +++ b/migrate/modules/appService.bicep @@ -38,7 +38,73 @@ param privateAcr bool param privateAcrUri string // ACR Uri Variable -var acrUri = privateAcr ? privateAcrUri : 'azureipam.azurecr.io' +var acrUri = privateAcr ? privateAcrUri : 'registry.azureipam.com' + +// Shared App Service site configuration (reused by the production site and the staging slot) +var appServiceSiteConfig = { + acrUseManagedIdentityCreds: privateAcr ? true : false + acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null + alwaysOn: true + linuxFxVersion: 'DOCKER|${acrUri}/ipam:latest' + appCommandLine: '' + healthCheckPath: '/api/status' + appSettings: concat( + [ + { + name: 'AZURE_ENV' + value: azureCloud + } + { + name: 'COSMOS_URL' + value: cosmosDbUri + } + { + name: 'DATABASE_NAME' + value: databaseName + } + { + name: 'CONTAINER_NAME' + value: containerName + } + { + name: 'MANAGED_IDENTITY_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + } + { + name: 'UI_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + } + { + name: 'ENGINE_APP_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + } + { + name: 'ENGINE_APP_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + } + { + name: 'TENANT_ID' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + } + { + name: 'KEYVAULT_URL' + value: keyVaultUri + } + { + name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' + value: '2' + } + { + name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' + value: 'true' + } + { + name: 'DOCKER_REGISTRY_SERVER_URL' + value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + ] + ) +} resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { name: appServicePlanName @@ -69,78 +135,32 @@ resource appService 'Microsoft.Web/sites@2021-02-01' = { httpsOnly: true serverFarmId: appServicePlan.id keyVaultReferenceIdentity: managedIdentityId - siteConfig: { - acrUseManagedIdentityCreds: privateAcr ? true : false - acrUserManagedIdentityID: privateAcr ? managedIdentityClientId : null - alwaysOn: true - linuxFxVersion: 'DOCKER|${acrUri}/ipam:latest' - appCommandLine: '' - healthCheckPath: '/api/status' - appSettings: concat( - [ - { - name: 'AZURE_ENV' - value: azureCloud - } - { - name: 'COSMOS_URL' - value: cosmosDbUri - } - // { - // name: 'COSMOS_KEY' - // value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/COSMOS-KEY/)' - // } - { - name: 'DATABASE_NAME' - value: databaseName - } - { - name: 'CONTAINER_NAME' - value: containerName - } - { - name: 'MANAGED_IDENTITY_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' - } - { - name: 'UI_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' - } - { - name: 'ENGINE_APP_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' - } - { - name: 'ENGINE_APP_SECRET' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' - } - { - name: 'TENANT_ID' - value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' - } - { - name: 'KEYVAULT_URL' - value: keyVaultUri - } - { - name: 'WEBSITE_HEALTHCHECK_MAXPINGFAILURES' - value: '2' - } - { - name: 'WEBSITE_ENABLE_SYNC_UPDATE_SITE' - value: 'true' - } - { - name: 'DOCKER_REGISTRY_SERVER_URL' - value: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' - } - ] - ) + siteConfig: appServiceSiteConfig + } +} + +// Staging slot (created dormant; started on demand for slot-based upgrades/swaps) +resource appServiceStagingSlot 'Microsoft.Web/sites/slots@2021-02-01' = { + name: 'staging' + parent: appService + location: location + kind: 'app,linux,container' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} } } + properties: { + enabled: false + httpsOnly: true + serverFarmId: appServicePlan.id + keyVaultReferenceIdentity: managedIdentityId + siteConfig: appServiceSiteConfig + } } -resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { +resource appServiceLogs 'Microsoft.Web/sites/config@2021-02-01' = { name: 'logs' parent: appService properties: { @@ -160,6 +180,26 @@ resource appConfigLogs 'Microsoft.Web/sites/config@2021-02-01' = { } } +resource appServiceStagingSlotLogs 'Microsoft.Web/sites/slots/config@2021-02-01' = { + name: 'logs' + parent: appServiceStagingSlot + properties: { + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 50 + } + } + } +} + resource diagnosticSettingsPlan 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { name: 'diagSettings' scope: appServicePlan @@ -263,3 +303,4 @@ resource diagnosticSettingsApp 'Microsoft.Insights/diagnosticSettings@2021-05-01 } output appServiceHostName string = appService.properties.defaultHostName +output appServiceStagingSlotHostName string = appServiceStagingSlot.properties.defaultHostName 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..4885abeb 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,1969 @@ 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 + + $spaces, $spacesStatus = Get-ApiResource '/spaces' - # POST /api/spaces - It 'Create Two Spaces' { - $spaceA = @{ - name = 'TestSpace01' - desc = 'Test Space 1' + $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 - # DELETE /api/spaces/{space} - It 'Delete a Space' { - Remove-ApiResource '/spaces/TestSpace02' + $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' + + $spaceStatus | Should -Be 200 + + $space.Name | Should -Be 'TestSpaceA' + $space.Desc | Should -Be 'Test Space A' + } - $spaces, $spacesStatus = Get-ApiResource '/spaces' + # PATCH /api/spaces/{space} + It 'Reject Updating Space with Invalid Name Format' { + $update = @( + @{ + op = 'replace' + path = '/name' + value = '-InvalidSpaceName' + } + ) - $spaces.Count | Should -Be 1 - $spaces.Name -contains 'TestSpace01' | Should -Be $true - $spaces.Name -contains 'TestSpace02' | Should -Be $false + { 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 + + $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' + + $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' + } + + # 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 - # POST /api/spaces/{space}/blocks - It 'Create Two Blocks' { - $blockA = @{ - name = 'TestBlock01' - cidr = '10.0.0.0/16' + $updatedBlock.Name | Should -Be 'TestBlockA' + $updatedBlock.Cidr | Should -Be '10.1.0.0/16' } - $blockB = @{ - name = 'TestBlock02' - cidr = '192.168.0.0/24' + # GET /api/spaces/{space}/blocks/{block} + It 'Get a Specific Block' { + + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + + $blockStatus | Should -Be 200 + + $block.Name | Should -Be 'TestBlockA' + $block.Cidr | Should -Be '10.1.0.0/16' } - New-ApiResource '/spaces/TestSpaceA/blocks' $blockA - New-ApiResource '/spaces/TestSpaceA/blocks' $blockB + # 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' + } - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $newBlock, $newBlockStatus = New-ApiResource '/spaces/TestSpaceA/blocks' $overlapBlock - $blocks.Count | Should -Be 2 - $blocks.Name -contains 'TestBlock01' | Should -Be $true - $blocks.Name -contains 'TestBlock02' | Should -Be $true - } + $newBlockStatus | Should -Be 201 + + $newBlock.Name | Should -Be 'TestBlockOverlap' + + $update = @( + @{ + op = 'replace' + path = '/cidr' + value = '10.1.0.0/24' + } + ) - # DELETE /api/spaces/{space}/blocks/{block} - It 'Delete a Block' { - Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlock02' + { Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockOverlap' $update } | Should -Throw - $blocks, $blocksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks' + $block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockOverlap' - $blocks.Count | Should -Be 1 - $blocks.Name -contains 'TestBlock01' | Should -Be $true - $blocks.Name -contains 'TestBlock02' | Should -Be $false + $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' + + $currentBlock, $currentBlockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA' + + $currentBlockStatus | Should -Be 200 + + $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' + + $networksStatus | Should -Be 200 + + ($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 + ) - # 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' + { Set-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' $body } | Should -Throw - Start-Sleep -Seconds 60 + $networks, $networksStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/networks' - $body = @{ - id = $script:newNetA.Id + $networksStatus | Should -Be 200 + + ($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 - # 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 | Should -Not -BeNullOrEmpty + + $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 - # 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 | Select-Object -ExpandProperty name) | Should -Not -Contain 'ExternalNetOutside' } - New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalB + # 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' + } - $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $overlapExternal } | Should -Throw - $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[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 - } + ($externals | Select-Object -ExpandProperty name) | Should -Not -Contain 'ExternalNetOverlap' + } + + # 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 + } - # GET /api/spaces/{space}/blocks/{block}/externals/{external} - It 'Get a Specific External Network' { + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' $script:externalB - $external, $externalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $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 - } + $externalsStatus | Should -Be 200 + $externals.Count | Should -Be 2 + + $externalA = $externals | Where-Object { $_.Name -eq 'ExternalNetA' } | Select-Object -First 1 + $externalB = $externals | Where-Object { $_.Name -eq 'ExternalNetB' } | Select-Object -First 1 + + $externalA | Should -Not -BeNullOrEmpty + $externalB | Should -Not -BeNullOrEmpty + + $externalA.Name | Should -Be "ExternalNetA" + $externalA.Desc | Should -Be "External Network A" + $externalA.Cidr | Should -Be "10.1.1.0/24" + + $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' + + $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 + } - # PATCH /api/spaces/{space}/blocks/{block}/externals/{external} - It 'Update an External Network' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'ExternalNetC' + # 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" + } - # 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" + # 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' + + $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 + + $subnetA = $subnets | Where-Object { $_.Name -eq 'SubnetA' } | Select-Object -First 1 + + $subnetA | Should -Not -BeNullOrEmpty - # 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.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' { - $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 0 + } - $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 + # 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[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 - } + New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $script:endpointA - # GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet} - It 'Get Specific External Subnet' { + $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' - $subnet, $subnetStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' + $endpointsStatus | Should -Be 200 + $endpoints.Count | Should -Be 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 = $endpoints | Where-Object { $_.Name -eq 'EndpointA' } | Select-Object -First 1 + + $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' + } + + { New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' $duplicateNameEndpoint } | Should -Throw - # 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' + $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 2 + } - $subnets.Count | Should -Be 1 + # 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" + } - $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 - } + $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 + + $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 - # 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 | 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 + + $endpointA | Should -Not -BeNullOrEmpty + $endpointB | Should -Not -BeNullOrEmpty - # 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.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 + } + + # 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 - # PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint} - It 'Update an External Endpoint' { - $update = @( - @{ - op = 'replace' - path = '/name' - value = 'EndpointC' + ($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 - # 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' + $subnet.Name | Should -Be $script:forceDeleteEndpointSubnet.name + ($subnet.Endpoints | Select-Object -ExpandProperty name) | Should -Contain $script:forceDeleteEndpoint.name + } - $endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints' + # 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.Count | Should -Be 1 + $subnets, $subnetsStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)/subnets" - $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 - } -} + $subnetsStatus | Should -Be 200 + + if($subnets) { + ($subnets | Select-Object -ExpandProperty name) | Should -Not -Contain $script:forceDeleteEndpointSubnet.name + } + else { + $subnets.Count | Should -Be 0 + } + + Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/externals/$($script:forceDeleteEndpointExternal.name)" -Context 'Reservations' { - # GET /api/spaces/{space}/blocks/{block}/reservations - It 'Verify No Reservations Exist in Block' { + $externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals' - $reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' + $externalsStatus | Should -Be 200 - $reservations | Should -Be $null + ($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" + } + + $bodyC = @{ + size = 24 + desc = "Test Reservation C" + } - # DELETE /api/spaces/{space}/blocks/{block}/reservations/{reservationId} - It 'Delete a Specific Reservation' { - $query = @{ - settled = $true + $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 - # 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' + $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 - Start-Sleep -Seconds 60 + # 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') + + Start-Sleep -Seconds 60 + + # Associate the vNET with TestBlockA + $body = @{ + id = $script:newNetMultiPfx.Id + } - $body = @{ - id = $script:toolsNet.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 + + $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' + } - # POST /api/tools/nextAvailableSubnet - It 'Check Next Available Subnet in Tools vNET' { - $body = @{ - vnet_id = $script:toolsNet.Id - size = 26 + 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 } + + 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' { - $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + $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 + } - $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 + # 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)" + + $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 + $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' } - $cidrCheck, $cidrCheckStatus = New-ApiResource '/tools/cidrCheck' $body + # 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 - $containers = @( - @{ - space = "ToolsSpace" - block = "ToolsBlock" + $spaces, $spacesStatus = Get-ApiResource '/spaces' + + $spacesStatus | Should -Be 200 + + $spaces.Name | Should -Contain 'TestSpaceA' + $spaces.Name | Should -Contain 'ToolsSpace' + } + + # POST /api/spaces/{space}/blocks + It 'Create Tools Block' { + $toolsBlock = @{ + name = 'ToolsBlock' + cidr = '198.51.100.0/24' } - ) - $subscriptionId = ($script:toolsNet.Id | Select-String -Pattern '(?<=subscriptions/).*(?=/resourceGroups)').Matches.Value + New-ApiResource '/spaces/ToolsSpace/blocks' $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 + } + + $newNet, $newNetStatus = New-ApiResource '/tools/nextAvailableVNet' $body + + $newNetStatus | Should -Be 200 - $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 + $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' + + 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 'Notifications' { + # GET /api/notifications + It 'Verify Notifications list' { - $status, $statusCode = Get-ApiResource '/status' + $notifications, $status = Get-ApiResource '/notifications' - $statusCode | Should -Be 200 + $status | 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 + # The envelope always carries a 'notifications' collection (possibly empty). + $notifications.PSObject.Properties.Name | Should -Contain 'notifications' + + # When notifications are present, each must be well-formed and self-describing. + foreach ($notification in $notifications.notifications) { + $notification.id | Should -Not -BeNullOrEmpty + $notification.title | Should -Not -BeNullOrEmpty + $notification.message | Should -Not -BeNullOrEmpty + $notification.category | Should -Not -BeNullOrEmpty + $notification.severity | Should -BeIn @('critical', 'warning', 'information') + $notification.audience | Should -BeIn @('all', 'admin') + } + } + + # POST /api/notifications/{id}/resolve + It 'Returns an error resolving an unknown notification' { + + { New-ApiResource '/notifications/does-not-exist/resolve' @{} } | Should -Throw -ExpectedMessage '*404*' + } + + # POST /api/notifications/{id}/resolve + It 'Returns an error resolving a notification with no remediation' { + + # 'update-available' is a real, link-only notification: it never carries a + # server-owned resolve action, so resolving it is always a 404. + { New-ApiResource '/notifications/update-available/resolve' @{} } | Should -Throw -ExpectedMessage '*404*' + } + + # POST /api/notifications/{id}/resolve + It 'Returns an error resolving a remediable notification that is not active' { + + # Never active against the test deployment's private ACR, so this hits the + # active-gate (409) instead of triggering a real remediation/restart. + { New-ApiResource '/notifications/registry-migration/resolve' @{} } | Should -Throw -ExpectedMessage '*409*' + } + } + + Context 'Health' { + # GET /api/health + It 'Verify Health' { + + $health, $healthStatus = Get-ApiResource '/health' + + $healthStatus | Should -Be 200 + + $health.ok | Should -Be $true + + $checkNames = $health.checks.PSObject.Properties.Name + + foreach ($check in @('config', 'cosmos', 'arm', 'schema')) { + $checkNames | Should -Contain $check + $health.checks.$check.ok | Should -Be $true + } + } + } + + Context 'Status' { + # GET /api/status + It 'Verify Status' { + + $status, $statusCode = Get-ApiResource '/status' + + $statusCode | Should -Be 200 + + $status.status | Should -Be 'OK' + $status.stack | Should -Be 'AppContainer' + $status.environment | Should -Be 'AZURE_PUBLIC' + } } } diff --git a/tools/build.ps1 b/tools/build.ps1 index 88aaf774..7b1dc81e 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -45,7 +45,7 @@ param( $ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName # Define minimum NodeJS and NPM versions required to build the Azure IPAM UI solution -$MIN_NODE_VERSION = [version]'22.12.0' +$MIN_NODE_VERSION = [version]'22.22.0' $MIN_NPM_VERSION = [version]'10.9.2' # Load Python version required to build the Azure IPAM UI solution @@ -272,10 +272,15 @@ try { Copy-Item -Path ..\engine\app -Destination $tempFolder -Recurse Copy-Item -Path ..\engine\host.json -Destination $tempFolder Copy-Item -Path ..\engine\function_app.py -Destination $tempFolder - Copy-Item -Path ..\engine\requirements.txt -Destination $tempFolder Copy-Item -Path ..\ui\dist -Destination $tempFolder -Recurse Copy-Item -Path ..\init.sh -Destination $tempFolder + if ($ManifestOnly) { + Copy-Item -Path ..\engine\requirements.txt -Destination $tempFolder + } else { + Copy-Item -Path ..\engine\requirements.lock.txt -Destination (Join-Path -Path $tempFolder -ChildPath "requirements.txt") + } + Get-ChildItem -Path (Join-Path -Path $tempFolder -ChildPath "app") -Filter "__pycache__" -Recurse | Remove-Item -Recurse Write-Host "INFO: Creating ZIP Deploy archive..." -ForegroundColor Green diff --git a/ui/Dockerfile.deb b/ui/Dockerfile.deb index b71a3f2e..cdda815c 100644 --- a/ui/Dockerfile.deb +++ b/ui/Dockerfile.deb @@ -1,26 +1,41 @@ ARG BASE_IMAGE=node:22-slim FROM $BASE_IMAGE +# Set Production Build Flag +ARG PROD_BUILD=true + # Set Default Port ARG PORT=80 # Set Debian Frontend to Non-Interactive ARG DEBIAN_FRONTEND=noninteractive +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} # Disable NPM Update Warnings ENV NPM_CONFIG_UPDATE_NOTIFIER=false +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM UI" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Set the Working Directory WORKDIR /ipam # Add Application Sources -ADD . . +COPY . . # Install Dependencies -RUN npm ci +RUN if [ "${PROD_BUILD}" = true ]; then \ + npm ci; \ +else \ + npm install; \ +fi # Build Application RUN npm run build @@ -33,4 +48,4 @@ RUN rm -rf src EXPOSE $PORT # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/ui/Dockerfile.dev b/ui/Dockerfile.dev index b5b9e29e..4fc0e13a 100644 --- a/ui/Dockerfile.dev +++ b/ui/Dockerfile.dev @@ -31,4 +31,4 @@ COPY . ./ EXPOSE $PORT # start app -CMD npx vite --host "0.0.0.0" --port $PORT +CMD ["/bin/sh", "-c", "npx vite --host 0.0.0.0 --port $PORT"] diff --git a/ui/Dockerfile.rhel b/ui/Dockerfile.rhel index 15b0025a..9a2dfeae 100644 --- a/ui/Dockerfile.rhel +++ b/ui/Dockerfile.rhel @@ -1,15 +1,26 @@ -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 +# IPAM Version (for OCI Image Label) +ARG IPAM_VERSION=0.0.0 + # Set Environment Variable ENV PORT=${PORT} # Disable NPM Update Warnings ENV NPM_CONFIG_UPDATE_NOTIFIER=false +# OCI Image Labels +LABEL org.opencontainers.image.title="Azure IPAM UI" \ + org.opencontainers.image.version="${IPAM_VERSION}" \ + org.opencontainers.image.source="https://github.com/Azure/ipam" + # Set the Working Directory WORKDIR /ipam @@ -17,10 +28,14 @@ WORKDIR /ipam USER root # Add Application Sources -ADD . . +COPY . . # Install Dependencies -RUN npm ci +RUN if [ "${PROD_BUILD}" = true ]; then \ + npm ci; \ +else \ + npm install; \ +fi # Build Application RUN npm run build @@ -37,4 +52,4 @@ USER 1001 EXPOSE $PORT # Execute Startup Script -ENTRYPOINT ./init.sh ${PORT} +ENTRYPOINT ["/bin/sh", "-c", "exec ./init.sh ${PORT}"] diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 98ccad66..17c423e1 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -1,141 +1,106 @@ 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 reactHooks from "eslint-plugin-react-hooks"; 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) + // Disable rules-of-hooks and exhaustive-deps (covered by react-hooks) + // Disable set-state-in-effect (widespread pattern, to be addressed incrementally) { 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", + "@eslint-react/rules-of-hooks": "off", + "@eslint-react/exhaustive-deps": "off", + "@eslint-react/set-state-in-effect": "off", }, }, + + // React Hooks + React Compiler rules (consolidated in eslint-plugin-react-hooks v7) { - files: ["src/**/*.ts"], - plugins: { - jest - }, - languageOptions: { - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - globals: { - ...globals.node, - ...globals.browser, - ...globals.jest - } - }, + ...reactHooks.configs.flat["recommended-latest"], + files: ["src/**/*.jsx"], + }, + + // React Compiler rule overrides + // - set-state-in-effect: widespread pattern, to be addressed incrementally + { + files: ["src/**/*.jsx"], rules: { - "no-unused-vars": "off", - "no-prototype-builtins": "off", - "no-constant-binary-expression": "off" + "react-hooks/set-state-in-effect": "off", }, }, + + // 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/init.sh b/ui/init.sh index b1707789..8054df85 100644 --- a/ui/init.sh +++ b/ui/init.sh @@ -29,4 +29,4 @@ echo "}" >> env.js mv env.js ./dist/ -npx serve -s -L -p ${PORT} dist +exec npx serve -s -L -p ${PORT} dist diff --git a/ui/package-lock.json b/ui/package-lock.json index 33819a29..ad5fde39 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,114 +1,92 @@ { "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.15.0", + "@azure/msal-react": "^5.5.0", "@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", - "echarts": "^6.0.0", - "echarts-for-react": "^3.0.4", - "globals": "^16.4.0", - "lodash": "^4.17.21", + "@mui/icons-material": "^9.1.1", + "@mui/material": "^9.1.2", + "@reduxjs/toolkit": "^2.12.0", + "ag-grid-community": "^36.0.0", + "ag-grid-react": "^36.0.0", + "axios": "^1.18.1", + "dayjs": "^1.11.21", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", + "lodash-es": "^4.18.1", "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-redux": "^9.2.0", - "react-router": "^7.8.2", - "spinners-react": "^1.0.11", - "web-vitals": "^5.1.0" + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-redux": "^9.3.0", + "react-router": "^8.0.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" + "@eslint-react/eslint-plugin": "^5.9.3", + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.5.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", + "serve": "^14.2.6", + "vite": "^8.1.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=22.22.0", + "npm": ">=10.9.2" } }, "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.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.15.0.tgz", + "integrity": "sha512-2NYT6v+eeQn8kmNddr9LnbXSvXbVELpmFMmfFvtRxD7I/5+5GlkMlncApeuRFj+mY6C9syOwQip1a0Y+TIbyiA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.12.0" + "@azure/msal-common": "16.10.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.10.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.10.0.tgz", + "integrity": "sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w==", "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.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-5.5.0.tgz", + "integrity": "sha512-pP80mQBiK+HifywxQxP0PcKM3i/JWgstIGHiRyaLvLuF2NMed2B+hOz04lPCGBv+a4nZE3piDNlu0TqNFS5DkA==", "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.15.0", + "react": "^16.8.0 || ^17 || ^18 || ^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.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -117,9 +95,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.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -127,22 +105,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.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -165,13 +143,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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -181,14 +159,14 @@ } }, "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -198,37 +176,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "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.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -237,38 +215,28 @@ "@babel/core": "^7.0.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", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "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", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -276,26 +244,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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "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.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -304,92 +272,118 @@ "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, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "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==", - "dev": true, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, "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==", + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "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==", + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "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==", + "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.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "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.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "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 +522,320 @@ "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": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-5.9.3.tgz", + "integrity": "sha512-je5lXEnIjs454kTs3x5Jw/7GCbJbccIqjhECZqH3myo3m/zckhYQCsdzmMITGd6zzP25A+jw3jw6w/9st9BOTg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/typescript-estree": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "string-ts": "^2.3.1" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-5.9.3.tgz", + "integrity": "sha512-vv1sXIhPcq5wSXttw4WoE6EufaA3w2ey1i9vroKwc9SxnoxpJBH0ReaC9AZ+sTmGwIz8msqWQU4hAl0vtmI+4A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/jsx": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/scope-manager": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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/eslint": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint/-/eslint-5.9.3.tgz", + "integrity": "sha512-x/29XHfW4XtntFEOinsxCJsoa4LTWmYLGQtzEvH7WAgbMRabaaAgbOP4u5uWQdgCNOx3Yq0cgu+cE9G7KWCaIA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@typescript-eslint/utils": "^8.62.0" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-5.9.3.tgz", + "integrity": "sha512-+JJ2JEZtFCxaVtqbAqLzROltBSuCmV7F04YWbZiuR8KPcdOX2GT/zKKD3tN7rFuJxSyAUAtSzQUycbMCbvG41g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@eslint-react/shared": "5.9.3", + "eslint-plugin-react-dom": "5.9.3", + "eslint-plugin-react-jsx": "5.9.3", + "eslint-plugin-react-naming-convention": "5.9.3", + "eslint-plugin-react-rsc": "5.9.3", + "eslint-plugin-react-web-api": "5.9.3", + "eslint-plugin-react-x": "5.9.3" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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/jsx": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-5.9.3.tgz", + "integrity": "sha512-caY0YnLjXcFYCBQ1T2XkjuOh7rAPcXbgn6oJ/bNYFRDAt6juRjxmGZcwc+3j/YOSClHuyO6wOrFdsO1O/Vfq+Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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/shared": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-5.9.3.tgz", + "integrity": "sha512-Ydu7Jw4FQXWSRIiC0MbnmiSKjLaFURNDQ6VA9aj/spmOFpg+CZOFOCQKj8OsxFmwXn4ja16c9d78Ro/ffAxNVA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@eslint-react/eslint": "5.9.3", + "@typescript-eslint/utils": "^8.62.0", + "ts-pattern": "^5.9.0", + "zod": "^3.25.0 || ^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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-react/var": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-5.9.3.tgz", + "integrity": "sha512-4SLx2Ql2F8cFFRK2Dj9jPGx5Mx91WVoUkTGbPlDB4UXnEuokkeciiVu6QPezig8ffm0FPkgDSgKdQgAJlDvz7Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@typescript-eslint/scope-manager": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, - "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": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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" - ], + "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-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" - ], + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "balanced-match": "^4.0.2" + }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" } }, - "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" - ], + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "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_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, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "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" - } - }, - "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==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "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_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "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.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "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.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^1.2.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 +900,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,6 +910,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "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", @@ -1222,9 +945,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": "9.1.2", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.1.2.tgz", + "integrity": "sha512-ZMufoA/YFOEVp48lskcAOTlQYwpdBk4Z++4yUgPDEfuLHIpxBx9g+urGmIBKOtr+7M0ZlYfCxSvrJpEE/S32sg==", "license": "MIT", "funding": { "type": "opencollective", @@ -1232,12 +955,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": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.1.1.tgz", + "integrity": "sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3" + "@babel/runtime": "^7.29.2" }, "engines": { "node": ">=14.0.0" @@ -1247,7 +970,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.3.2", + "@mui/material": "^9.1.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1257,67 +980,23 @@ } } }, - "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==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/system": "^7.3.2", - "@mui/types": "^7.4.6", - "@mui/utils": "^7.3.2", - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material": "^7.3.2", - "@mui/material-pigment-css": "^7.3.2", - "@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" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, "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": "9.1.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.2.tgz", + "integrity": "sha512-CN2U1etAL+6qZT2XjJR1Ibv7nyE2wBN3/28b5XpXjQFMtBKNlD45wQupODfJrm9PLanJ1DefocHWIQZ5PkSipQ==", "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.29.2", + "@mui/core-downloads-tracker": "^9.1.2", + "@mui/system": "^9.1.2", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.1.1", "@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.6", "react-transition-group": "^4.4.5" }, "engines": { @@ -1330,7 +1009,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.2", + "@mui/material-pigment-css": "^9.1.1", "@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 +1030,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": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.1.1.tgz", + "integrity": "sha512-oH6c+d6sJ1CZT0Vg2/fHdUQ5zvo9Pn+f+WWk0tlQliHqqIRdN32DZ7UxjalW3LUj4OkHbdWR31biWuLxK9i7Cg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/utils": "^7.3.2", + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.1.1", "prop-types": "^15.8.1" }, "engines": { @@ -1378,16 +1057,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": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", + "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", + "@babel/runtime": "^7.29.2", "@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 +1091,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": "9.1.2", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.1.2.tgz", + "integrity": "sha512-oJxyyummOR6nV8ODF/yugasJ//pSsQxxfYCE9q9RU2Hef0f5RRzJ75M9zr5NvHDhzhGgrPstkaNrJtmcuz/Pdg==", "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.29.2", + "@mui/private-theming": "^9.1.1", + "@mui/styled-engine": "^9.1.1", + "@mui/types": "^9.1.1", + "@mui/utils": "^9.1.1", "clsx": "^2.1.1", - "csstype": "^3.1.3", + "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "engines": { @@ -1452,12 +1131,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": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", + "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3" + "@babel/runtime": "^7.29.2" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1469,17 +1148,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": "9.1.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.1.1.tgz", + "integrity": "sha512-qSNfnkzZMptaaWFFklpDf4NPJztgwsMDVfM/sSDt+wq4ssYSBhLYwwjuB6eS/+p2IUYbeRzHluzXbw0Zn7aI4A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/types": "^7.4.6", + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.1.1", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.1" + "react-is": "^19.2.6" }, "engines": { "node": ">=14.0.0" @@ -1498,59 +1177,54 @@ } } }, - "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.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@tybys/wasm-util": "^0.10.3" }, - "engines": { - "node": ">= 8" - } - }, - "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==", - "dev": true, - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "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.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "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": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, "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.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "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 +1242,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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -1624,12 +1254,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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -1638,12 +1271,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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -1652,26 +1288,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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -1680,26 +1305,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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -1708,166 +1322,188 @@ "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "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" + "libc": [ + "musl" ], - "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "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" + "libc": [ + "glibc" ], - "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ - "s390x" + "x64" ], "dev": true, + "libc": [ + "glibc" + ], "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "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.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -1876,7 +1512,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.0.0", @@ -1890,135 +1536,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.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "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 +1576,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 +1621,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.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "@typescript-eslint/visitor-keys": "8.15.0" + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2115,13 +1638,77 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.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.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "license": "MIT", + "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.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.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.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2131,19 +1718,21 @@ } }, "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==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, + "license": "MIT", "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" + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2152,42 +1741,55 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.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.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "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.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "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.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2196,15 +1798,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.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "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.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2214,22 +1817,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.1.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.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.15.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2240,24 +1840,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.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "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.1" }, "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 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/@zeit/schemas": { @@ -2267,9 +1872,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 +1894,58 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-charts-types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz", + "integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "36.0.0", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.0.tgz", + "integrity": "sha512-73PeuoG1Pofi6o1o2dYd092m5AdCt0uq/iCaVTCxLw9eRJGLZu0I+HICReolUmUH7A5X+CnYDXiE+P9S8dUqCQ==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "14.0.0", + "ag-stack": "36.0.0" + } + }, + "node_modules/ag-grid-react": { + "version": "36.0.0", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.0.0.tgz", + "integrity": "sha512-ShmynyRQDoxuXBso+kPliZJnivQKwBG/EGclCBV3dg98ou2kq7yxdgf8DhrW4uM5SekNDaqor4Gn9OAGtH2o3g==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "36.0.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/ag-stack": { + "version": "36.0.0", + "resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.0.tgz", + "integrity": "sha512-ciXqjZygVLk3tnZWOAQyOxP48Ju2ZmaSAtIebekXJwHqA7vB+NJ8ZME/O4XTRpInTSHn2KSu/KTxgWJO8dduiw==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.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 +2004,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 +2013,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,194 +2050,22 @@ "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.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -2605,6 +2088,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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 +2153,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.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -2683,10 +2174,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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2700,27 +2192,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 +2210,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 +2231,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.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -2798,6 +2255,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" @@ -2865,6 +2323,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -2873,6 +2332,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 +2343,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 +2358,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 +2435,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" } @@ -2976,14 +2445,11 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -3031,74 +2497,22 @@ "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, - "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/ljharb" - } - }, - "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/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3127,40 +2541,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 +2550,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", @@ -3227,19 +2591,19 @@ "dev": true }, "node_modules/echarts": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", - "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", "license": "Apache-2.0", "dependencies": { "tslib": "2.3.0", - "zrender": "6.0.0" + "zrender": "6.1.0" } }, "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 +2615,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.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", "dev": true, "license": "ISC" }, @@ -3271,75 +2635,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 +2652,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 +2679,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 +2701,33 @@ } }, "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.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "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.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@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.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3539,8 +2737,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 +2745,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 +2759,269 @@ } } }, - "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-dom": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.9.3.tgz", + "integrity": "sha512-3+hPO6BkN47AbyEJbXaVS5B4aa9jJ8TGOL95mPYbCUfGhgRFqn5zkgvML4McOqI62WtldeqoE6MNLyOoCZSC2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.0.0" + "@eslint-react/ast": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/jsx": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "compare-versions": "^6.1.1" }, "engines": { - "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + "node": ">=22.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0", - "eslint": "^8.57.0 || ^9.0.0", - "jest": "*" + "eslint": "*", + "typescript": "*" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.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-jsx": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.9.3.tgz", + "integrity": "sha512-4enKdwMXRp7jp3doa9yvPakgVHCSpyZZTF6UlzJIg78cpi6afYKArhOnJTthkMO6i6K2W7fDRfpKh9zknUhgaA==", "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": "5.9.3", + "@eslint-react/core": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/jsx": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0" }, "engines": { - "node": ">=4" + "node": ">=22.0.0" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "eslint": "*", + "typescript": "*" } }, - "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": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.9.3.tgz", + "integrity": "sha512-mCRlWRQm3kDzzKTPHQ2e/fLm021VKfGw0HQo953BIkkV146+cmGf2VERxunQH9qDzmuq4tf2KeVJggGYS3LXdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/core": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + } + }, + "node_modules/eslint-plugin-react-rsc": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.9.3.tgz", + "integrity": "sha512-EmfGYvj2ISdrNSbHT4WTuXxO1TQEuyLlEvb4O69fhyqkMujjCjql9xrCVZg9rhXTlandoB5/kgmdg6v2ompffQ==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/core": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0" + }, "engines": { - "node": ">=10" + "node": ">=22.0.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": "*", + "typescript": "*" } }, - "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-web-api": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.9.3.tgz", + "integrity": "sha512-be12vzvZV13NAiuUiEmZ77x9Zxj0yVG10ba6TGiaXTmZY7SlYJkaZni+m7E8vNG5Vtn54xNjNZYmlJYpCATq/w==", "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": "5.9.3", + "@eslint-react/core": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "birecord": "^0.1.1", + "ts-pattern": "^5.9.0" }, - "bin": { - "resolve": "bin/resolve" + "engines": { + "node": ">=22.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "*", + "typescript": "*" + } + }, + "node_modules/eslint-plugin-react-x": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-5.9.3.tgz", + "integrity": "sha512-Dq29dj3Xbu/td2BX1oNtQy3vZsOkS4VJavFlvywTl0Mm1K5bF0FI6k+cIz5mSG95v6l0Biq55B5dr6bLqWcpMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.9.3", + "@eslint-react/core": "5.9.3", + "@eslint-react/eslint": "5.9.3", + "@eslint-react/jsx": "5.9.3", + "@eslint-react/shared": "5.9.3", + "@eslint-react/var": "5.9.3", + "@typescript-eslint/scope-manager": "^8.62.0", + "@typescript-eslint/type-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/typescript-estree": "^8.62.0", + "@typescript-eslint/utils": "^8.62.0", + "compare-versions": "^6.1.1", + "string-ts": "^2.3.1", + "ts-api-utils": "^2.5.0", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" } }, "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 +3047,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 +3061,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 +3089,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 +3102,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 +3149,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 +3190,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.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -3933,26 +3209,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==", + "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 +3231,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3987,36 +3248,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 +3307,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 +3320,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.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4118,22 +3332,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 +3352,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 +3398,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", @@ -4253,6 +3428,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4273,9 +3461,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 +3494,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 +3524,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 +3548,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 +3557,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,370 +3569,382 @@ "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==", + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "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": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "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==", - "dev": true, - "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "is-docker": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-port-reachable": { + "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/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", - "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "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" + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "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==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, - "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==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "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/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, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "json-buffer": "3.0.1" } }, - "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==", + "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, - "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8.0" } }, - "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==", + "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": "MIT", + "license": "MPL-2.0", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "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" + "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": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "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": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "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": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "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": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "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, - "dependencies": { - "is-docker": "^2.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "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" + "node": ">= 12.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "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": "MIT", - "dependencies": { - "argparse": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "bin": { - "jsesc": "bin/jsesc" - }, + "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": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "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": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/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, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4.0" + "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": { @@ -4943,51 +3967,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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==" + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -5010,15 +3994,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 +4019,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 +4031,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 +4048,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" }, @@ -5135,23 +4070,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -5184,11 +4111,14 @@ } }, "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.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/notistack": { "version": "3.0.2", @@ -5240,102 +4170,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 +4212,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 +4283,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 +4304,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", @@ -5503,9 +4321,9 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5523,20 +4341,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.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -5554,7 +4362,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5571,38 +4379,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", @@ -5619,44 +4395,30 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/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,54 +4448,36 @@ } }, "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.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "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.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "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.7" } }, "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.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -5753,31 +4497,20 @@ } } }, - "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": "8.0.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.0.1.tgz", + "integrity": "sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==", "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "cookie-es": "^3.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.22.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { "react-dom": { @@ -5801,73 +4534,17 @@ "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" - } - }, - "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 +4574,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 +4584,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,97 +4608,38 @@ "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.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "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==", - "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" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/safe-buffer": { @@ -6049,68 +4663,31 @@ ], "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", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "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 +4695,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 +4706,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 +4726,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 +4736,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 +4745,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,68 +4777,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 - }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "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" - } + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -6280,82 +4801,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", @@ -6366,162 +4811,59 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz", "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==" - }, - "node_modules/source-map": { - "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" - }, + }, + "node_modules/source-map": { + "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.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "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 +4902,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 +4911,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" }, @@ -6612,14 +4931,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6628,30 +4947,26 @@ "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==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "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==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, + "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 +4997,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": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, + "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -6774,29 +5012,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 +5058,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,18 +5083,17 @@ } }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6890,9 +5109,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.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 +5125,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -6937,43 +5160,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 +5175,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 +5235,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,10 +5247,33 @@ "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": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zrender": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", - "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", "license": "BSD-3-Clause", "dependencies": { "tslib": "2.3.0" diff --git a/ui/package.json b/ui/package.json index 68ea03ad..85587ad3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,61 +3,49 @@ "version": "3.6.0", "type": "module", "private": true, + "engines": { + "node": ">=22.22.0", + "npm": ">=10.9.2" + }, "dependencies": { - "@azure/msal-browser": "^4.22.1", - "@azure/msal-react": "^3.0.19", + "@azure/msal-browser": "^5.15.0", + "@azure/msal-react": "^5.5.0", "@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", - "echarts": "^6.0.0", - "echarts-for-react": "^3.0.4", - "globals": "^16.4.0", - "lodash": "^4.17.21", + "@mui/icons-material": "^9.1.1", + "@mui/material": "^9.1.2", + "@reduxjs/toolkit": "^2.12.0", + "ag-grid-community": "^36.0.0", + "ag-grid-react": "^36.0.0", + "axios": "^1.18.1", + "dayjs": "^1.11.21", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", + "lodash-es": "^4.18.1", "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-redux": "^9.2.0", - "react-router": "^7.8.2", - "spinners-react": "^1.0.11", - "web-vitals": "^5.1.0" + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-redux": "^9.3.0", + "react-router": "^8.0.1", + "spinners-react": "^1.0.11" }, "scripts": { "start": "vite", - "build": "vite build" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix" }, "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": "^5.9.3", + "@eslint/js": "^10.0.1", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.5.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", + "serve": "^14.2.6", + "vite": "^8.1.0" } } diff --git a/ui/src/App.jsx b/ui/src/App.jsx index b0366777..53112abe 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -13,13 +13,13 @@ import { ThemeProvider, createTheme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { SnackbarProvider } from 'notistack'; -import { SnackbarUtilsConfigurator } from './utils/snackbar'; 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 +43,7 @@ function App() { return (
+ - diff --git a/ui/src/App.test.jsx b/ui/src/App.test.jsx deleted file mode 100644 index 659cc13d..00000000 --- a/ui/src/App.test.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { store } from './app/store'; -import App from './App'; - -test('renders learn react link', () => { - const { getByText } = render( - - - - ); - - expect(getByText(/learn/i)).toBeInTheDocument(); -}); diff --git a/ui/src/app/store.jsx b/ui/src/app/store.jsx index b220e641..ea952663 100644 --- a/ui/src/app/store.jsx +++ b/ui/src/app/store.jsx @@ -1,8 +1,12 @@ import { configureStore } from '@reduxjs/toolkit'; import ipamReducer from '../features/ipam/ipamSlice'; +import notificationsReducer from '../features/notifications/notificationsSlice'; +import restartReducer from '../features/restart/restartSlice'; export const store = configureStore({ reducer: { ipam: ipamReducer, + notifications: notificationsReducer, + restart: restartReducer, }, }); diff --git a/ui/src/features/DiscoverTable/Table.jsx b/ui/src/features/DiscoverTable/Table.jsx index 0aa8c86f..19df4f53 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 filterAppliedRef = 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 && !filterAppliedRef.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); + filterAppliedRef.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 - ); + filterAppliedRef.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); + filterAppliedRef.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)}> @@ -551,7 +209,7 @@ export default function DiscoverTable(props) { right: -300, height: "calc(100vh - 64px)", backgroundColor: "transparent", - ...menuExpand ? openStyle : closedStyle + ...(menuExpand ? openStyle : closedStyle) }} > { 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/Details.jsx b/ui/src/features/DiscoverTable/Utils/Details.jsx index 479ae6d9..06990d36 100644 --- a/ui/src/features/DiscoverTable/Utils/Details.jsx +++ b/ui/src/features/DiscoverTable/Utils/Details.jsx @@ -1,13 +1,11 @@ import React from "react"; -import { styled } from '@mui/system'; -import { createTheme } from "@mui/material/styles"; +import { styled, createTheme, ThemeProvider } from "@mui/material/styles"; -import { get } from 'lodash'; +import { get } from 'lodash-es'; import { CircularProgress, Typography, - ThemeProvider, Button, Box, Divider, @@ -22,28 +20,49 @@ import { TableContext } from "../TableContext"; const detailsTheme = (theme) => createTheme({ ...theme, - overrides: { - MuiLinearProgress: { - root: { - borderRadius: 4, - height: 7, - }, - bar1Determinate: { - borderRadius: 4, - }, - colorPrimary: { - backgroundColor: "#f5f5f5", - }, - }, + components: { MuiCircularProgress: { - circle: { - strokeLinecap: "round", - strokeWidth: 2.8, + styleOverrides: { + circle: { + strokeLinecap: "round", + strokeWidth: 2.8, + }, }, }, }, }); +const Wrapper = styled(Box)({ + display: "flex", + justifyContent: "center", + flexDirection: "column", + paddingTop: "8px", + paddingBottom: "8px", +}); + +const Utilization = styled(Box)({ + display: "flex", + alignItems: "center", + justifyContent: "center", + paddingTop: "8px", + paddingBottom: "8px", +}); + +const Fields = styled(Box)({ + display: "flex", + flexDirection: "column", + paddingTop: "8px", + paddingBottom: "8px", +}); + +const Link = styled(Box)({ + display: "flex", + alignItems: "center", + justifyContent: "center", + paddingTop: "8px", + paddingBottom: "8px", +}); + function NumberCircularProgress(props) { var circleColor = "inherit"; @@ -56,8 +75,19 @@ function NumberCircularProgress(props) { } return ( - - + + + sx={{ + top: 0, + left: 0, + bottom: 0, + right: 0, + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center" + }}> @@ -152,9 +152,9 @@ export default function ItemDetails(props) { } - {map.fieldMap.map((field, index) => ( + {map.fieldMap.map((field) => ( get(rowData, field.value) != null && - + {field.name}:  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..6ff695b6 --- /dev/null +++ b/ui/src/features/DiscoverTable/Utils/InfoCellRenderer.jsx @@ -0,0 +1,76 @@ +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..7a55a5b7 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-es'; -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.use(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,178 +321,29 @@ 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 ( - + Nothing yet... ); - } + }, []); return ( - + @@ -651,21 +351,23 @@ 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]); @@ -723,7 +724,7 @@ const Search = React.forwardRef((props, ref) => { }} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); @@ -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,14 @@ 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) { + // Manual memo retained; compiler bails due to mutation + mixed ref access below + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const setDataFocus = React.useCallback((target) => { if(target) { let newOptions = cloneDeep(options); @@ -862,7 +865,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..ea0e72d7 100644 --- a/ui/src/features/analysis/visualize/visualize.jsx +++ b/ui/src/features/analysis/visualize/visualize.jsx @@ -1,7 +1,7 @@ import React from "react"; import { useSelector } from 'react-redux'; -import { concat } from 'lodash'; +import { concat, cloneDeep, isEmpty } from 'lodash-es'; import ReactECharts from "echarts-for-react"; @@ -15,8 +15,6 @@ import { import RestoreIcon from '@mui/icons-material/Restore'; -import { cloneDeep, isEmpty } from "lodash"; - import { selectSpaces, selectUpdatedVNets, @@ -332,13 +330,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 +540,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(''); @@ -603,7 +600,7 @@ const Search = React.forwardRef((props, ref) => { }} renderOption={(props, option) => { return ( -
  • +
  • {option}
  • ); @@ -620,7 +617,7 @@ const Search = React.forwardRef((props, ref) => { }} /> ); -}); +}; const Visualize = () => { const [options, setOptions] = React.useState(opt); @@ -656,6 +653,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 +677,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 +701,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..7fbe4d83 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-es"; 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,288 +267,266 @@ 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 ( - - - - - option.name} - inputValue={spaceInput} - onInputChange={(event, newInputValue) => setSpaceInput(newInputValue)} - value={selectedSpace} - onChange={(event, newValue) => setSelectedSpace(newValue)} - isOptionEqualToValue={ - (option, value) => { - const newOption = pick(option, ['name']); - const newValue = pick(value, ['name']); - - return isEqual(newOption, newValue); - } + + + + option.name} + inputValue={spaceInput} + onInputChange={(event, newInputValue) => setSpaceInput(newInputValue)} + value={selectedSpace} + onChange={(event, newValue) => setSelectedSpace(newValue)} + isOptionEqualToValue={ + (option, value) => { + const newOption = pick(option, ['name']); + const newValue = pick(value, ['name']); + + return isEqual(newOption, newValue); } - noOptionsText={ !spaces ? "Loading..." : "No Spaces" } - sx={{ width: 300 }} - renderInput={(params) => ( - ( + {!spaces ? : null} - {params.InputProps.endAdornment} + {params.slotProps.input.endAdornment} ), - }} - /> - )} - renderOption={(props, option) => { - return ( -
  • - {option.name} -
  • - ); - }} - componentsProps={{ - paper: { - sx: { - width: 'fit-content' } + }} + /> + )} + renderOption={(props, option) => { + return ( +
  • + {option.name} +
  • + ); + }} + slotProps={{ + paper: { + sx: { + width: 'fit-content' } - }} - /> - x.parent_space === selectedSpace.name), 'name') : []} - getOptionLabel={(option) => option.name} - inputValue={blockInput} - onInputChange={(event, newInputValue) => setBlockInput(newInputValue)} - value={(selectedBlock?.parent_space === selectedSpace?.name) ? selectedBlock : null} - onChange={(event, newValue) => setSelectedBlock(newValue)} - isOptionEqualToValue={ - (option, value) => { - const newOption = pick(option, ['id', 'name']); - const newValue = pick(value, ['id', 'name']); - - return isEqual(newOption, newValue); - } } - sx={{ width: 300 }} - renderInput={(params) => ( - - )} - renderOption={(props, option) => { - return ( -
  • - {option.name} -
  • - ); - }} - componentsProps={{ - paper: { - sx: { - width: 'fit-content' + }} + /> + x.parent_space === selectedSpace.name), 'name') : []} + getOptionLabel={(option) => option.name} + inputValue={blockInput} + onInputChange={(event, newInputValue) => setBlockInput(newInputValue)} + value={(selectedBlock?.parent_space === selectedSpace?.name) ? selectedBlock : null} + onChange={(event, newValue) => setSelectedBlock(newValue)} + isOptionEqualToValue={ + (option, value) => { + const newOption = pick(option, ['id', 'name']); + const newValue = pick(value, ['id', 'name']); + + return isEqual(newOption, newValue); + } + } + sx={{ width: 300 }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + return ( +
  • + {option.name} +
  • + ); + }} + slotProps={{ + paper: { + sx: { + width: 'fit-content' } - }} - /> - + +
    + + + - - - - - Selected: - - - - - { - (sending || !subscriptions || !spaces || !blocks || !vNets || refreshing ) ? - (...) : - ({Object.keys(selectionModel).length}/{vNets ? vNets.length : '?'}) - } - - - - - - - - - - - + display: 'block', + fontStyle: 'italic', + userSelect: 'none' + }}> + Selected: + - - - - - - - - + + + { + (sending || !subscriptions || !spaces || !blocks || !vNets || refreshing ) ? + (...) : + ({selectedRows.length}/{vNets ? vNets.length : '?'}) + } + - - + - 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} - /> - + + + + + + + + + + + refreshData()} + disabled={sending || refreshing || !selectedSpace || !selectedBlock } + > + + + + + + + + + -
    + ); } diff --git a/ui/src/features/configure/basics/basics.jsx b/ui/src/features/configure/basics/basics.jsx index 892be546..6aa818fd 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) })); @@ -103,7 +103,7 @@ export default function Basics() { // }, [blocks, selectedBlock]); return ( - + @@ -122,6 +122,6 @@ export default function Basics() { - + ); } diff --git a/ui/src/features/configure/basics/block/block.jsx b/ui/src/features/configure/basics/block/block.jsx index 0afd07d1..07b509dd 100644 --- a/ui/src/features/configure/basics/block/block.jsx +++ b/ui/src/features/configure/basics/block/block.jsx @@ -4,13 +4,9 @@ import { styled } from "@mui/material/styles"; import { useNavigate } from "react-router"; -import { isEmpty} from "lodash"; +import { isEmpty} from "lodash-es"; -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, @@ -25,10 +21,10 @@ import { import { Edit as EditIcon, - DeleteOutline as DeleteOutlineIcon, + DeleteOutlined as DeleteOutlineIcon, MoreVert as MoreVertIcon, GridView as GridViewIcon, - PieChartOutline as PieChartOutlineIcon, + PieChartOutlined as PieChartOutlineIcon, SettingsEthernet as SettingsEthernetIcon, MapOutlined as MapOutlinedIcon } from "@mui/icons-material"; @@ -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 }, @@ -74,10 +64,9 @@ const columns = [ export default function BlockDataGrid(props) { const { selectedSpace, selectedBlock, setSelectedBlock } = props; - const { blocks, refreshing, refresh } = React.useContext(BasicContext); + const { blocks, refreshing, refresh } = React.use(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,33 +142,36 @@ 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 - ? + ? No Blocks Found in Selected Space - : + : Please Select a Space } ); - } + }, [selectedSpace]); + + // Compute row data for the grid + const rowData = React.useMemo(() => { + return (selectedSpace && blocks) ? blocks.filter((block) => block.parent_space === selectedSpace.name) : []; + }, [selectedSpace, blocks]); return ( @@ -241,32 +235,34 @@ export default function BlockDataGrid(props) { vertical: "top", horizontal: "right", }} - PaperProps={{ - elevation: 0, - style: { - width: 200, - }, - sx: { - overflow: "visible", - filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", - mt: 1.5, - "& .MuiAvatar-root": { - width: 32, - height: 32, - ml: -0.5, - mr: 1, + slotProps={{ + paper: { + elevation: 0, + style: { + width: 200, }, - "&:before": { - content: '""', - display: "block", - position: "absolute", - top: 0, - right: 14, - width: 10, - height: 10, - bgcolor: "background.paper", - transform: "translateY(-50%) rotate(45deg)", - zIndex: 0, + sx: { + overflow: "visible", + filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", + mt: 1.5, + "& .MuiAvatar-root": { + width: 32, + height: 32, + ml: -0.5, + mr: 1, + }, + "&:before": { + content: '""', + display: "block", + position: "absolute", + top: 0, + right: 14, + width: 10, + height: 10, + bgcolor: "background.paper", + transform: "translateY(-50%) rotate(45deg)", + zIndex: 0, + }, }, }, }} @@ -331,22 +327,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..52042138 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; @@ -134,7 +116,12 @@ export default function AddBlock(props) { Add Block - + { onNameChange(event); }} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onCidrChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> - 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..ebed3974 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; @@ -164,7 +146,12 @@ export default function EditBlock(props) { Edit Block - + { onNameChange(event); }} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onCidrChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> - Update - + diff --git a/ui/src/features/configure/basics/space/space.jsx b/ui/src/features/configure/basics/space/space.jsx index 1b7ba1a7..a6d310cb 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, @@ -24,7 +18,7 @@ import { import { Edit as EditIcon, - DeleteOutline as DeleteOutlineIcon, + DeleteOutlined as DeleteOutlineIcon, MoreVert as MoreVertIcon } from "@mui/icons-material"; @@ -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 }, @@ -70,9 +58,7 @@ const columns = [ export default function SpaceDataGrid(props) { const { selectedSpace, setSelectedSpace, setSelectedBlock } = props; - const { spaces, refresh } = React.useContext(BasicContext); - - const [selectionModel, setSelectionModel] = React.useState({}); + const { spaces, refresh } = React.use(BasicContext); const [addSpaceOpen, setAddSpaceOpen] = React.useState(false); const [editSpaceOpen, setEditSpaceOpen] = 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,30 +119,21 @@ 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 ( - + No Spaces Found, Create a Space to Begin ); - } + }, []); return ( @@ -211,32 +194,34 @@ export default function SpaceDataGrid(props) { vertical: "top", horizontal: "right", }} - PaperProps={{ - elevation: 0, - style: { - width: 200, - }, - sx: { - overflow: "visible", - filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", - mt: 1.5, - "& .MuiAvatar-root": { - width: 32, - height: 32, - ml: -0.5, - mr: 1, + slotProps={{ + paper: { + elevation: 0, + style: { + width: 200, }, - "&:before": { - content: '""', - display: "block", - position: "absolute", - top: 0, - right: 14, - width: 10, - height: 10, - bgcolor: "background.paper", - transform: "translateY(-50%) rotate(45deg)", - zIndex: 0, + sx: { + overflow: "visible", + filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", + mt: 1.5, + "& .MuiAvatar-root": { + width: 32, + height: 32, + ml: -0.5, + mr: 1, + }, + "&:before": { + content: '""', + display: "block", + position: "absolute", + top: 0, + right: 14, + width: 10, + height: 10, + bgcolor: "background.paper", + transform: "translateY(-50%) rotate(45deg)", + zIndex: 0, + }, }, }, }} @@ -277,23 +262,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..a707230b 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; @@ -136,7 +118,12 @@ export default function AddSpace(props) { Add Space - + { onNameChange(event); }} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onDescriptionChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> - 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..06762499 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; @@ -164,7 +146,12 @@ export default function EditSpace(props) { Edit Space - + { onNameChange(event); }} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onDescriptionChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> - Update - + diff --git a/ui/src/features/configure/externals/externals.jsx b/ui/src/features/configure/externals/externals.jsx index 35e9733a..b6ed34c8 100644 --- a/ui/src/features/configure/externals/externals.jsx +++ b/ui/src/features/configure/externals/externals.jsx @@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { useLocation } from "react-router"; import { styled } from "@mui/material/styles"; -import { isEqual, sortBy, pick } from "lodash"; +import { isEqual, sortBy, pick } from "lodash-es"; import { useSnackbar } from "notistack"; @@ -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]); @@ -197,7 +197,7 @@ export default function Externals() { }, [subnets, selectedSubnet]); return ( - + @@ -215,7 +215,7 @@ export default function Externals() { (option, value) => { const newOption = pick(option, ['name']); const newValue = pick(value, ['name']); - + return isEqual(newOption, newValue); } } @@ -226,25 +226,29 @@ export default function Externals() { {...params} label="Space" placeholder="Please Select Space..." - InputProps={{ - ...params.InputProps, - endAdornment: ( - - {!spaces ? : null} - {params.InputProps.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + endAdornment: ( + + {!spaces ? : null} + {params.slotProps.input.endAdornment} + + ), + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -267,7 +271,7 @@ export default function Externals() { (option, value) => { const newOption = pick(option, ['id', 'name']); const newValue = pick(value, ['id', 'name']); - + return isEqual(newOption, newValue); } } @@ -277,19 +281,23 @@ export default function Externals() { {...params} label="Block" placeholder="Please Select Block..." - InputProps={{ - ...params.InputProps + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -347,6 +355,6 @@ export default function Externals() {
    -
    + ); } diff --git a/ui/src/features/configure/externals/networks/networks.jsx b/ui/src/features/configure/externals/networks/networks.jsx index dedec8d0..312fa152 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-es"; -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 + DeleteOutlined } 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, @@ -248,249 +35,97 @@ const Networks = (props) => { setExternals, setSelectedExternal } = 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 { refreshing } = React.use(ExternalContext); 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: DeleteOutlined, + 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..7dfcabed 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,102 +224,120 @@ 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 - - } - > - 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 - - } + + + 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)} + sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} + /> +
    + + - 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)} + sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} + /> +
    + + + 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' }} - ListboxProps={{ - style: { - maxHeight: "15rem" - }, - position: "bottom-start" - }} + onChange={(_, newValue) => setSelectedMask(newValue)} + sx={{ width: '7ch' }} renderInput={(params) => ( )} + slotProps={{ + listbox: { + style: { maxHeight: "15rem" }, + position: "bottom-start" + } + }} /> - - 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" }} + slotProps={{ + htmlInput: { spellCheck: false } }} />
    -
    + )}
    -
    - - - - 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..f564e8ad 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]); @@ -259,7 +240,12 @@ export default function EditExtNetwork(props) { Edit External Network - + onNameChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onDescChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onCidrChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> @@ -349,13 +341,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..636853ff 100644 --- a/ui/src/features/configure/externals/subnets/subnets.jsx +++ b/ui/src/features/configure/externals/subnets/subnets.jsx @@ -1,42 +1,23 @@ 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-es"; -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, + DeleteOutlined, EditNoteOutlined } 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, @@ -265,18 +38,7 @@ const Subnets = (props) => { setSubnets, setSelectedSubnet } = 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 { refreshing } = React.use(ExternalContext); const [addExtSubOpen, setAddExtSubOpen] = React.useState(false); const [editExtSubOpen, setEditExtSubOpen] = React.useState(false); @@ -284,231 +46,96 @@ const Subnets = (props) => { 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: DeleteOutlined, + 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..733021c1 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,102 +196,120 @@ 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 - - } - > - 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 - - } + + + 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)} + sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} + /> +
    + + - 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)} + sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} + /> +
    + + + 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' }} - ListboxProps={{ - style: { - maxHeight: "15rem" - }, - position: "bottom-start" - }} + onChange={(_, newValue) => setSelectedMask(newValue)} + sx={{ width: '7ch' }} renderInput={(params) => ( )} + slotProps={{ + listbox: { + style: { maxHeight: "15rem" }, + position: "bottom-start" + } + }} /> - - 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" }} + slotProps={{ + htmlInput: { spellCheck: false } }} />
    -
    + )}
    -
    - - - - 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..13e9ae09 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]); @@ -231,7 +212,12 @@ export default function EditExtSubnet(props) { Edit External Subnet - + onNameChange(event)} - inputProps={{ spellCheck: false }} sx={{width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onDescChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> onCidrChange(event)} - inputProps={{ spellCheck: false }} sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } + }} /> @@ -321,13 +313,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..645d2571 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-es"; 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,94 @@ 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+) +// ============================================================================ + +// Context for passing reactive overlay config to CombinedOverlay. +const OverlayContext = React.createContext(null); + +const CombinedOverlay = React.memo(({ overlayType }) => { + const overlayConfig = React.use(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 RenderDelete(props) { - const { value } = props; - const { setChanges, selectionModel } = React.useContext(EndpointContext); +function DeleteCellRenderer(props) { + const { data } = props; + const { setChanges, selectedRow } = React.use(EndpointContext); const flexCenter = { display: "flex", alignItems: "center", - justifyContent: "center" - } + justifyContent: "center", + height: "100%" + }; + + const isSelected = selectedRow && selectedRow.id === data.id; return ( @@ -108,23 +180,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]); }} > - {/* */} @@ -134,7 +200,7 @@ function RenderDelete(props) { function HeaderMenu(props) { const { setting } = props; - const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.useContext(EndpointContext); + const { saving, sendResults, saveConfig, loadConfig, resetConfig } = React.use(EndpointContext); const [menuOpen, setMenuOpen] = React.useState(false); @@ -144,22 +210,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 ( { @@ -192,6 +260,7 @@ function HeaderMenu(props) { @@ -259,21 +330,6 @@ function HeaderMenu(props) { } - ) -} - -function DraggablePaper(props) { - const nodeRef = React.useRef(null); - - return ( - - - ); } @@ -286,7 +342,7 @@ export default function ManageExtEndpoints(props) { external, subnet } = props; - const { refreshing, refresh } = React.useContext(ExternalContext); + const { refreshing, refresh } = React.use(ExternalContext); const { enqueueSnackbar } = useSnackbar(); @@ -295,13 +351,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 +365,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 saveTimerRef = 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; - } - }); - - setColumnState(newColumns); - } + // Grid theme with compactness + const gridTheme = React.useMemo(() => { + const baseParams = { + spacing: 7, // default is 8, reduced for tighter layout + }; - 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,77 +503,45 @@ 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; + const api = gridRef.current?.api; + if (!api || !viewSetting) return; - 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]); + // 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(); - } + // Handle grid ready + const onGridReady = React.useCallback(() => { + // Auto-load saved view if available + if (viewSetting?.columnState) { + loadConfig(); } - },[columns, viewSetting, columnState, loadConfig, resetConfig]); - - React.useEffect(() => { - if(columnSortState) { - setGridData( - orderBy( - endpoints, - [columnSortState.name], - [columnSortState.dir === -1 ? 'desc' : 'asc'] - ) - ); - } else { - setGridData(endpoints); - } - },[endpoints, columnSortState]); + }, [viewSetting, loadConfig]); React.useEffect(() => { if(sendResults !== null) { - clearTimeout(saveTimer.current); + clearTimeout(saveTimerRef.current); - saveTimer.current = setTimeout( + saveTimerRef.current = setTimeout( function() { setSendResults(null); }, 2000 ); } - }, [saveTimer, sendResults]); + }, [saveTimerRef, sendResults]); + + function onAddExternal() { if(!hasError) { @@ -524,12 +553,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 +627,7 @@ export default function ManageExtEndpoints(props) { if (open) { handleClose(); - setSelectionModel({}); + setSelectedRow(null); setChanges([]); setEndName({ value: "", error: true }); @@ -608,13 +637,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 +647,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 { @@ -656,6 +678,8 @@ export default function ManageExtEndpoints(props) { }); } + // Manual memo retained; compiler bails on broader component (large stateful form) + // eslint-disable-next-line react-hooks/preserve-manual-memoization const hasError = React.useMemo(() => { const errorCheck = (endName.error || endDesc.error); const emptyCheck = (endName.value.length === 0 || endDesc.value.length === 0 || endAddr === null); @@ -713,17 +737,19 @@ export default function ManageExtEndpoints(props) { }, [subnet, changes, onCancel]); return ( - + @@ -747,7 +773,7 @@ export default function ManageExtEndpoints(props) { sx={{ overflowY: "unset" }} > - 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 +789,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 +824,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 +886,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 +948,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' @@ -950,18 +976,25 @@ export default function ManageExtEndpoints(props) { variant="standard" // label="IP Address" placeholder="IP Address" - InputProps={{ - ...params.InputProps, - disableUnderline: true, - spellCheck: false, - style: { - fontSize: '14px', - fontFamily: 'Roboto, Helvetica, Arial, sans-serif', - padding: '4px 0px 5px' + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + disableUnderline: true, + spellCheck: false, + style: { + fontSize: '14px', + fontFamily: 'Roboto, Helvetica, Arial, sans-serif', + padding: '4px 0px 5px' + } } }} /> )} + slotProps={{ + listbox: { style: { maxHeight: 375 } } + }} /> { - 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,16 +1117,15 @@ 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..0dfbadfa 100644 --- a/ui/src/features/configure/reservations/reservations.jsx +++ b/ui/src/features/configure/reservations/reservations.jsx @@ -1,27 +1,21 @@ 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-es"; import { useSnackbar } from "notistack"; -import moment from "moment"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; -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"; +dayjs.extend(localizedFormat); + +import { DataGrid } from "../../../global/grids/DataGrid"; import { Box, IconButton, - Menu, - MenuItem, - ListItemIcon, - Divider, TextField, Autocomplete, Typography, @@ -35,15 +29,9 @@ import { Autorenew, CheckOutlined, WarningAmber, - ErrorOutline, + ErrorOutlined, BlockOutlined, TimerOffOutlined, - ExpandCircleDownOutlined, - FileDownloadOutlined, - FileUploadOutlined, - ReplayOutlined, - TaskAltOutlined, - CancelOutlined, VisibilityOutlined, VisibilityOffOutlined, PieChartOutlined, @@ -55,9 +43,7 @@ import { selectSpaces, selectBlocks, fetchSpacesAsync, - deleteBlockResvsAsync, - selectViewSetting, - updateMeAsync + deleteBlockResvsAsync } from "../../ipam/ipamSlice"; import NewReservation from "./utils/newReservation"; @@ -93,9 +79,14 @@ const MESSAGE_MAP = { icon: WarningAmber, color: "warning" }, + "errCIDROverlap": { + msg: "A vNET with overlapping CIDR has already been associated with the target IP Block.", + icon: ErrorOutlined, + color: "error" + }, "errCIDRExists": { - msg: "A vNET with the assigned CIDR has already been associated with the target IP Block.", - icon: ErrorOutline, + msg: "A vNET with overlapping CIDR has already been associated with the target IP Block.", + icon: ErrorOutlined, color: "error" }, "cancelledByUser": { @@ -112,192 +103,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; @@ -346,7 +151,7 @@ function ReservationStatus(props) { function ReservationId(props) { const { value } = props; - const { copied, setCopied } = React.useContext(ReservationContext); + const { copied, setCopied } = React.use(ReservationContext); const contentCopied = (copied === value.id); @@ -415,279 +220,113 @@ 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 msgTimerRef = 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 ? dayjs.unix(params.value).format('lll') : null, + filter: 'agDateColumnFilter', + filterParams: { + comparator: (filterDate, cellValue) => { + if (!cellValue) return -1; + const cellDate = dayjs.unix(cellValue).startOf('day').toDate(); + const filterDateStart = dayjs(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 ? dayjs.unix(params.value).format('lll') : null, + filter: 'agDateColumnFilter', + filterParams: { + comparator: (filterDate, cellValue) => { + if (!cellValue) return -1; + const cellDate = dayjs.unix(cellValue).startOf('day').toDate(); + const filterDateStart = dayjs(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 !== "") { - clearTimeout(msgTimer.current); + clearTimeout(msgTimerRef.current); - msgTimer.current = setTimeout( + msgTimerRef.current = setTimeout( function() { setCopied(""); }, 3000 ); } - }, [msgTimer, copied]); - - React.useEffect(() => { - if(sendResults !== null) { - clearTimeout(saveTimer.current); - - saveTimer.current = setTimeout( - function() { - setSendResults(null); - }, 2000 - ); - } - }, [saveTimer, sendResults]); + }, [msgTimerRef, copied]); React.useEffect(() => { if (spaces) { @@ -744,23 +383,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 +412,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,34 +427,37 @@ 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 - ? + ? { filterActive ? "No Active Reservations Found for Selected Block" : "No Reservations Found for Selected Block" } - : + : Please Select a Space & Block } ); - } + }, [selectedBlock, filterActive]); return ( - + setNewResvOpen(false)} @@ -863,25 +492,29 @@ const Reservations = () => { {...params} label="Space" placeholder="Please Select Space..." - InputProps={{ - ...params.InputProps, - endAdornment: ( - - {!spaces ? : null} - {params.InputProps.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + endAdornment: ( + + {!spaces ? : null} + {params.slotProps.input.endAdornment} + + ), + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -914,19 +547,23 @@ const Reservations = () => { {...params} label="Block" placeholder="Please Select Block..." - InputProps={{ - ...params.InputProps + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -950,7 +587,7 @@ const Reservations = () => { title="Remove" placement="top" style={{ - visibility: (isEmpty(selectionModel) || refreshing) ? 'hidden' : 'visible' + visibility: (isEmpty(selectedRows) || refreshing) ? 'hidden' : 'visible' }} > @@ -983,44 +620,23 @@ 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} /> -
    + ); } -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..8e6e96ca 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,28 +55,36 @@ 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() { var body = { - ...description.value.length > 0 && { desc: description.value }, - ...checked && { size: mask.value }, - ...checked && { reverse_search: reverseSearch }, - ...checked && { smallest_cidr: smallestCIDR }, - ...!checked && { cidr: cidr.value } + ...(description.value.length > 0 && { desc: description.value }), + ...(checked && { size: mask.value }), + ...(checked && { reverse_search: reverseSearch }), + ...(checked && { smallest_cidr: smallestCIDR }), + ...(!checked && { cidr: cidr.value }) }; (async () => { @@ -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,179 @@ 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 - - } - > - onDescriptionChange(event)} - inputProps={{ spellCheck: false }} - sx={{ width: "100%" }} - /> -
    - + + 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)} + sx={{ width: "80%" }} + slotProps={{ + htmlInput: { spellCheck: false } }} + /> +
    + + + Allocation Mode + + - - setChecked(true)} - value={true} - /> - - - - 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) => ( - - )} + Auto + Manual + + + + + {mode === "auto" ? ( + + option.name} + inputValue={maskInput} + onInputChange={(_, newInputValue) => setMaskInput(newInputValue)} + value={mask} + onChange={(_, newValue) => setMask(newValue)} + sx={{ width: '7ch', flexShrink: 0 }} + 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 }} /> - - setSmallestCIDR(prev => !prev)} + /> + } + label={Smallest CIDR} + /> + + + ) : ( + + + - Must be in valid CIDR notation format +
    - Example: 1.2.3.4/5 + + } > - onCidrChange(event)} + sx={{ width: "20ch" }} + slotProps={{ + htmlInput: { spellCheck: false } }} - > - - - Must be in valid CIDR notation format -
    - Example: 1.2.3.4/5 - - } - > -
    - onCidrChange(event)} - sx={{ - width: '21ch', - }} - /> -
    -
    -
    -
    -
    + /> +
    + )}
    -
    - - - - Create - - -
    -
    + + + + + + + ); } diff --git a/ui/src/features/drawer/drawer.jsx b/ui/src/features/drawer/drawer.jsx index 226ef66c..55e1d3f6 100644 --- a/ui/src/features/drawer/drawer.jsx +++ b/ui/src/features/drawer/drawer.jsx @@ -2,20 +2,23 @@ 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"; import { styled, alpha } from "@mui/material/styles"; import { SvgIcon } from "@mui/material"; -import { orderBy } from 'lodash'; +import { orderBy } from 'lodash-es'; 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 NotificationCenter from "../notifications/NotificationCenter"; +import ServiceRestartGate from "../restart/ServiceRestartGate"; import { AppBar, @@ -113,7 +116,7 @@ import { selectEndpoints } from "../ipam/ipamSlice"; -import { apiRequest } from "../../msal/authConfig"; + const Search = styled("div")(({ theme }) => ({ display: "flex", @@ -173,7 +176,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 +188,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 +359,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 +543,10 @@ export default function NavDrawer() { } setSearchData(newSearchData); + + if(vNets !== null && vHubs !== null && endpoints !== null) { + setDataLoaded(true); + } }, [vNets, vHubs, subnets, endpoints]); const filterOptions = createFilterOptions({ @@ -541,7 +562,7 @@ export default function NavDrawer() { > {navItems.map((navItem, navIndex) => { return ( - + {navItem.map((item, itemIndex) => { return item.hasOwnProperty('children') @@ -613,7 +634,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 +641,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("------------------"); @@ -729,32 +722,34 @@ export default function NavDrawer() { }} open={isMenuOpen} onClose={handleMenuClose} - PaperProps={{ - elevation: 0, - style: { - width: 200, - }, - sx: { - overflow: 'visible', - filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', - mt: 1.5, - '& .MuiAvatar-root': { - width: 32, - height: 32, - ml: -0.5, - mr: 1, + slotProps={{ + paper: { + elevation: 0, + style: { + width: 200, }, - '&:before': { - content: '""', - display: 'block', - position: 'absolute', - top: 0, - right: 26, - width: 10, - height: 10, - bgcolor: 'background.paper', - transform: 'translateY(-50%) rotate(45deg)', - zIndex: 0, + sx: { + overflow: 'visible', + filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', + mt: 1.5, + '& .MuiAvatar-root': { + width: 32, + height: 32, + ml: -0.5, + mr: 1, + }, + '&:before': { + content: '""', + display: 'block', + position: 'absolute', + top: 0, + right: 26, + width: 10, + height: 10, + bgcolor: 'background.paper', + transform: 'translateY(-50%) rotate(45deg)', + zIndex: 0, + }, }, }, }} @@ -804,32 +799,34 @@ export default function NavDrawer() { }} open={isMobileMenuOpen} onClose={handleMobileMenuClose} - PaperProps={{ - elevation: 0, - style: { - width: 200, - }, - sx: { - overflow: 'visible', - filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', - mt: 1.5, - '& .MuiAvatar-root': { - width: 32, - height: 32, - ml: -0.5, - mr: 1, + slotProps={{ + paper: { + elevation: 0, + style: { + width: 200, }, - '&:before': { - content: '""', - display: 'block', - position: 'absolute', - top: 0, - right: 19, - width: 10, - height: 10, - bgcolor: 'background.paper', - transform: 'translateY(-50%) rotate(45deg)', - zIndex: 0, + sx: { + overflow: 'visible', + filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', + mt: 1.5, + '& .MuiAvatar-root': { + width: 32, + height: 32, + ml: -0.5, + mr: 1, + }, + '&:before': { + content: '""', + display: 'block', + position: 'absolute', + top: 0, + right: 19, + width: 10, + height: 10, + bgcolor: 'background.paper', + transform: 'translateY(-50%) rotate(45deg)', + zIndex: 0, + }, }, }, }} @@ -897,7 +894,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,19 +908,23 @@ export default function NavDrawer() { renderInput={(params) => ( 0 ? "Search..." : "Loading..."} + placeholder={dataLoaded ? (searchData.length > 0 ? "Search..." : "No Resources...") : "Loading..."} fullWidth variant="standard" - InputProps={{ - ...params.InputProps, - startAdornment: - - - , - disableUnderline: true, - type: 'search', - sx: { - color: 'inherit' + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + startAdornment: + + + , + disableUnderline: true, + type: 'search', + sx: { + color: 'inherit' + } } }} /> @@ -937,6 +938,7 @@ export default function NavDrawer() { /> + */} + ); } diff --git a/ui/src/features/drawer/utils/about.jsx b/ui/src/features/drawer/utils/about.jsx index bdeb504d..67f92e3c 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 { @@ -25,8 +24,6 @@ import { // DarkModeOutlined, // } from "@mui/icons-material"; -// import LoadingButton from '@mui/lab/LoadingButton'; - // import { // getMeAsync, // getRefreshInterval, @@ -36,21 +33,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; @@ -204,10 +186,10 @@ export default function About(props) { - {/* */} - {/* + {/* */} diff --git a/ui/src/features/drawer/utils/refresh.jsx b/ui/src/features/drawer/utils/refresh.jsx index 53cdfa4f..035f9444 100644 --- a/ui/src/features/drawer/utils/refresh.jsx +++ b/ui/src/features/drawer/utils/refresh.jsx @@ -1,24 +1,53 @@ 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, getMeAsync } from '../../ipam/ipamSlice'; +import { fetchNotificationsAsync } from '../../notifications/notificationsSlice'; +import { getRestart } from '../../restart/restartSlice'; function Refresh() { - const intervalAll = React.useRef(null); - const intervalMe = React.useRef(null); - const refreshAllRef = React.useRef(); + const intervalAllRef = React.useRef(null); + const intervalMeRef = React.useRef(null); + const intervalNotificationsRef = React.useRef(null); + const refreshAllRef = React.useRef(null); const refreshMeRef = React.useRef(null); + const refreshNotificationsRef = React.useRef(null); const refreshLoadedRef = React.useRef(false); + const inProgressRef = React.useRef(InteractionStatus.None); + const restartActiveRef = React.useRef(false); const refreshInterval = useSelector(getRefreshInterval); + const restart = useSelector(getRestart); const dispatch = useDispatch(); + const { inProgress } = useMsal(); + + React.useEffect(() => { + inProgressRef.current = inProgress; + }, [inProgress]); + + // Pause all polling while a service restart is in progress; the app is cycling, + // so these calls would just error, and the restart gate drives recovery itself. + React.useEffect(() => { + restartActiveRef.current = restart.active; + }, [restart.active]); + + const refreshAll = React.useCallback(() => { + if (inProgressRef.current !== InteractionStatus.None) { + return; + } + + if (restartActiveRef.current) { + return; + } - refreshAllRef.current = React.useCallback(() => { (async() => { try { await dispatch(refreshAllAsync()); @@ -31,12 +60,20 @@ function Refresh() { })(); }, [dispatch]); - refreshMeRef.current = React.useCallback(() => { + const refreshMe = React.useCallback(() => { + if (inProgressRef.current !== InteractionStatus.None) { + return; + } + + if (restartActiveRef.current) { + 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("------------------"); @@ -44,33 +81,75 @@ function Refresh() { })(); }, [dispatch]); + const refreshNotifications = React.useCallback(() => { + if (inProgressRef.current !== InteractionStatus.None) { + return; + } + + if (restartActiveRef.current) { + return; + } + + (async() => { + try { + await dispatch(fetchNotificationsAsync()); + } catch (e) { + console.log("REFRESH NOTIFICATIONS ERROR"); + console.log("------------------"); + console.log(e); + console.log("------------------"); + } + })(); + }, [dispatch]); + + React.useEffect(() => { + refreshAllRef.current = refreshAll; + refreshMeRef.current = refreshMe; + refreshNotificationsRef.current = refreshNotifications; + }, [refreshAll, refreshMe, refreshNotifications]); + React.useEffect(() => { if(refreshInterval) { refreshAllRef.current() - clearInterval(intervalAll.current); - intervalAll.current = setInterval(() => refreshAllRef.current(), refreshInterval * 60 * 1000); + clearInterval(intervalAllRef.current); + intervalAllRef.current = setInterval(() => refreshAllRef.current(), refreshInterval * 60 * 1000); return () => { - clearInterval(intervalAll.current); - intervalAll.current = null; + clearInterval(intervalAllRef.current); + intervalAllRef.current = null; } } }, [refreshInterval]); React.useEffect(() => { - clearInterval(intervalMe.current); - intervalMe.current = setInterval(() => refreshMeRef.current(), 60 * 1000); + clearInterval(intervalMeRef.current); + intervalMeRef.current = setInterval(() => refreshMeRef.current(), 60 * 1000); return () => { - clearInterval(intervalMe.current); - intervalMe.current = null; + clearInterval(intervalMeRef.current); + intervalMeRef.current = null; } }, []); - React.useEffect(()=>{ - if(!refreshLoadedRef.current) { + React.useEffect(() => { + clearInterval(intervalNotificationsRef.current); + intervalNotificationsRef.current = setInterval(() => refreshNotificationsRef.current(), 15 * 60 * 1000); + return () => { + clearInterval(intervalNotificationsRef.current); + intervalNotificationsRef.current = null; + } + }, []); + + 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(); + refreshNotificationsRef.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..ef33c468 100644 --- a/ui/src/features/drawer/utils/userSettings.jsx +++ b/ui/src/features/drawer/utils/userSettings.jsx @@ -1,11 +1,14 @@ import * as React from "react"; import { useSelector, useDispatch } from 'react-redux'; -import { isEqual } from 'lodash'; +import { isEqual } from 'lodash-es'; 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..015f7e96 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) { @@ -308,3 +279,22 @@ export function fetchNextAvailableSubnet(body) { return api.post(url, body); } + +export function fetchNotifications() { + var url = new URL(`${ENGINE_URL}/api/notifications`); + + return api.get(url); +} + +export function resolveNotification(id) { + var url = new URL(`${ENGINE_URL}/api/notifications/${id}/resolve`); + + return api.post(url); +} + +// /api/status is public: use bare axios (no auth token) and unwrap data manually. +export function fetchStatus() { + var url = new URL(`${ENGINE_URL}/api/status`); + + return axios.get(url).then(response => response.data); +} diff --git a/ui/src/features/ipam/ipamSlice.jsx b/ui/src/features/ipam/ipamSlice.jsx index c47d43cd..b52277ba 100644 --- a/ui/src/features/ipam/ipamSlice.jsx +++ b/ui/src/features/ipam/ipamSlice.jsx @@ -1,6 +1,6 @@ import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; -import { concat, merge, cloneDeep, isEqual } from 'lodash'; +import { concat, merge, cloneDeep, isEqual } from 'lodash-es'; // import SnackbarUtils from '../../utils/snackbar'; @@ -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/login/login.jsx b/ui/src/features/login/login.jsx index a6c4edd8..bec5cfc3 100644 --- a/ui/src/features/login/login.jsx +++ b/ui/src/features/login/login.jsx @@ -7,7 +7,7 @@ import { loginRequest } from "../../msal/authConfig"; const Login = () => { const { instance, inProgress } = useMsal(); const isAuthenticated = useIsAuthenticated(); - const loginAttempted = React.useRef(false); + const loginAttemptedRef = React.useRef(false); React.useEffect(() => { const handleAuthentication = async () => { @@ -18,9 +18,9 @@ const Login = () => { if ( !isAuthenticated && inProgress === InteractionStatus.None && - !loginAttempted.current + !loginAttemptedRef.current ) { - loginAttempted.current = true; + loginAttemptedRef.current = true; try { await instance.loginRedirect(loginRequest); @@ -31,7 +31,7 @@ const Login = () => { console.log("--------------"); // Reset the attempt flag on any error to allow retry - loginAttempted.current = false; + loginAttemptedRef.current = false; } } }; @@ -42,7 +42,7 @@ const Login = () => { // Reset login attempt flag when authentication state changes React.useEffect(() => { if (isAuthenticated) { - loginAttempted.current = false; + loginAttemptedRef.current = false; } }, [isAuthenticated]); diff --git a/ui/src/features/notifications/NotificationCenter.jsx b/ui/src/features/notifications/NotificationCenter.jsx new file mode 100644 index 00000000..5df2c98a --- /dev/null +++ b/ui/src/features/notifications/NotificationCenter.jsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import IconButton from '@mui/material/IconButton'; +import Badge from '@mui/material/Badge'; +import Popover from '@mui/material/Popover'; +import Dialog from '@mui/material/Dialog'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import CircularProgress from '@mui/material/CircularProgress'; +import Tooltip from '@mui/material/Tooltip'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme, keyframes } from '@mui/material/styles'; + +import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; +import DoneAllIcon from '@mui/icons-material/DoneAll'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import CloseIcon from '@mui/icons-material/Close'; + +import { + getActiveNotifications, + getUnreadCount, + getWorstNotificationSeverity, + getNotificationsRefreshing, + dismissNotification, + markAllRead, + markRead, + fetchNotificationsAsync, +} from './notificationsSlice'; +import { getSeverityColor } from './utils/severityMeta'; +import NotificationListItem from './NotificationListItem'; +import NotificationDetailDialog from './NotificationDetailDialog'; +import { NOTIFICATION_SEVERITY } from './utils/notificationConstants'; + +const pulse = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.18); } + 100% { transform: scale(1); } +`; + +// Always-available home for every advisory: a bell + unread badge in the AppBar +// that opens a flat, severity-ordered list (popover on desktop, full-screen +// sheet on mobile). Selecting a row opens its detail dialog. +function NotificationCenter() { + const dispatch = useDispatch(); + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + + const notifications = useSelector(getActiveNotifications); + const unread = useSelector(getUnreadCount); + const worstSeverity = useSelector(getWorstNotificationSeverity); + const refreshing = useSelector(getNotificationsRefreshing); + + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + + const [selectedId, setSelectedId] = React.useState(null); + const selected = notifications.find((n) => n.id === selectedId) ?? null; + + // Subtle, brief attention pulse when an urgent item is present (respects + // reduced-motion). Deliberately not a forced panel open. + const urgent = worstSeverity === NOTIFICATION_SEVERITY.CRITICAL; + const [animate, setAnimate] = React.useState(false); + + React.useEffect(() => { + if (urgent && !reduceMotion) { + setAnimate(true); + const timer = setTimeout(() => setAnimate(false), 3600); + return () => clearTimeout(timer); + } + + setAnimate(false); + return undefined; + }, [urgent, reduceMotion]); + + const badgeColor = worstSeverity ? getSeverityColor(worstSeverity) : 'error'; + + const handleOpen = (event) => setAnchorEl(event.currentTarget); + const handleClose = () => setAnchorEl(null); + const handleDismiss = (notification) => dispatch(dismissNotification(notification.id)); + + const handleOpenItem = (notification) => { + setSelectedId(notification.id); + dispatch(markRead(notification.id)); + setAnchorEl(null); // close the list popover/sheet; the detail dialog takes over + }; + + const header = ( + + + Notifications + + + + dispatch(fetchNotificationsAsync({ manual: true }))} + > + + + + + {unread > 0 && ( + + dispatch(markAllRead())} + > + + + + )} + {fullScreen && ( + + + + )} + + ); + + const list = + refreshing ? ( + + + + ) : notifications.length === 0 ? ( + + + + You're all caught up. + + + ) : ( + + {notifications.map((notification, index) => ( + + + {index < notifications.length - 1 && } + + ))} + + ); + + const panel = ( + + {header} + + + {list} + + + ); + + return ( + + + + + + {notifications.length > 0 ? : } + + + + + + {fullScreen ? ( + + {panel} + + ) : ( + + `linear-gradient(${theme.palette.action.hover}, ${theme.palette.action.hover})`, + transform: 'translateY(-50%) rotate(45deg)', + zIndex: 0, + }, + }, + }, + }} + > + {panel} + + )} + + setSelectedId(null)} + onDismiss={handleDismiss} + /> + + ); +} + +export default NotificationCenter; diff --git a/ui/src/features/notifications/NotificationDetailDialog.jsx b/ui/src/features/notifications/NotificationDetailDialog.jsx new file mode 100644 index 00000000..32786fb1 --- /dev/null +++ b/ui/src/features/notifications/NotificationDetailDialog.jsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router'; +import { useSnackbar } from 'notistack'; + +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import CloseIcon from '@mui/icons-material/Close'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; + +import DraggablePaper from '../../global/DraggablePaper'; +import { getSeverityColor, SeverityIcon } from './utils/severityMeta'; +import { executeNotificationActionAsync, removeNotification } from './notificationsSlice'; +import { RESTART_REASON, DEFAULT_RESTART_REASON } from './utils/notificationConstants'; +import { getAdminStatus } from '../ipam/ipamSlice'; +import { beginServiceRestart } from '../restart/restartSlice'; + +// Draggable detail view for a single notification. Mirrors the app's existing +// dialog styling (plain title, no divider rules). Server actions report their +// outcome via toast notifications: success resolves + closes; failure keeps the +// notification so it can be addressed again later. +function NotificationDetailDialog({ notification, open, onClose, onDismiss }) { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + const isAdmin = useSelector(getAdminStatus); + + const [submitting, setSubmitting] = React.useState(false); + + const id = notification?.id; + + React.useEffect(() => { + setSubmitting(false); + }, [id, open]); + + if (!notification) { + return null; + } + + const severity = getSeverityColor(notification.severity); + + const allActions = notification.actions ?? []; + const docLinks = allActions.filter((a) => a.kind === 'link'); + const resolveActions = allActions.filter((a) => a.kind === 'resolve'); + const navigateActions = allActions.filter((a) => a.kind === 'navigate'); + + // Primary CTA(s): resolve actions if present, otherwise navigate actions. + const primaryActions = resolveActions.length ? resolveActions : navigateActions; + const secondaryActions = resolveActions.length ? navigateActions : []; + + const submit = async (action) => { + setSubmitting(true); + + try { + const result = await dispatch(executeNotificationActionAsync({ notification, action })).unwrap(); + enqueueSnackbar(result?.message ?? action.successText ?? 'Action completed.', { variant: 'success' }); + dispatch(removeNotification(notification.id)); + onClose(); + + // A remediation that restarts the service raises the full-screen gate so the + // user gets clear feedback while the app cycles back up. + if (action.causesRestart) { + const reason = RESTART_REASON[notification.id] ?? DEFAULT_RESTART_REASON; + dispatch(beginServiceRestart({ reason, verify: null })); + } + } catch (err) { + enqueueSnackbar(action.errorText ?? err?.message ?? 'Action failed.', { variant: 'error' }); + setSubmitting(false); + } + }; + + const handleNavigate = (action) => { + if (action.to) { + navigate(action.to); + onClose(); + } + }; + + const handleFooterAction = (action) => + action.kind === 'resolve' ? submit(action) : handleNavigate(action); + + const handleDocLink = (action) => { + if (action.href) { + window.open(action.href, '_blank', 'noopener,noreferrer'); + } + }; + + const handleDismiss = () => { + onDismiss(notification); + onClose(); + }; + + let footer; + + if (notification.dismissible || primaryActions.length > 0) { + footer = ( + + {notification.dismissible && ( + + )} + {secondaryActions.map((action) => ( + + ))} + {primaryActions.map((action) => { + const blocked = action.requiresAdmin && !isAdmin; + + const button = ( + + ); + + return blocked ? ( + + {button} + + ) : ( + button + ); + })} + + ); + } else { + footer = null; + } + + return ( + + + + {notification.title} + + {notification.category && ( + + + + {notification.category} + + + )} + event.stopPropagation()} + sx={{ position: 'absolute', right: 8, top: 8, color: 'text.secondary' }} + > + + + + + + + {notification.message} + + + {docLinks.length > 0 && ( + + {docLinks.map((action) => ( + handleDocLink(action)} + sx={{ + alignSelf: 'flex-start', + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + }} + > + {action.label} + + + ))} + + )} + + + {footer && {footer}} + + ); +} + +export default NotificationDetailDialog; diff --git a/ui/src/features/notifications/NotificationListItem.jsx b/ui/src/features/notifications/NotificationListItem.jsx new file mode 100644 index 00000000..c2d1adec --- /dev/null +++ b/ui/src/features/notifications/NotificationListItem.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; + +import CloseIcon from '@mui/icons-material/Close'; + +import { getSeverityColor, SeverityIcon } from './utils/severityMeta'; +import { getIsRead } from './notificationsSlice'; + +// A flat Material row: a leading unread dot, a severity-colored icon, and the +// title (clamped to two lines, uniform row height). Severity is conveyed by the +// icon color; unread by the leading dot + a bolder title. Read rows are plain. +function NotificationListItem({ notification, onOpen, onDismiss }) { + const color = getSeverityColor(notification.severity); + const isRead = useSelector((state) => getIsRead(state, notification.id)); + + return ( + + { + event.stopPropagation(); + onDismiss(notification); + }} + > + + + + ) : null + } + > + onOpen(notification)} + alignItems="center" + sx={{ px: 2, py: 1, pr: notification.dismissible ? 6 : 2, minHeight: 56 }} + > + + {!isRead && ( + + )} + + + + + + + + ); +} + +export default NotificationListItem; diff --git a/ui/src/features/notifications/notificationsSlice.jsx b/ui/src/features/notifications/notificationsSlice.jsx new file mode 100644 index 00000000..f132f8fd --- /dev/null +++ b/ui/src/features/notifications/notificationsSlice.jsx @@ -0,0 +1,139 @@ +import { createSlice, createSelector, createAsyncThunk } from '@reduxjs/toolkit'; + +import { getAdminStatus } from '../ipam/ipamSlice'; +import { fetchNotifications, resolveNotification } from '../ipam/ipamAPI'; +import { SEVERITY_PRIORITY, NOTIFICATION_AUDIENCE } from './utils/notificationConstants'; + +const initialState = { + items: [], // Notification[] — replaced wholesale by the backend fetch later + dismissed: [], // archived notification ids (non-destructive; retrievable later) + readIds: [], // notification ids the user has seen/acknowledged + refreshing: false, // true while a user-initiated manual refresh is in flight +}; + +// Execute a notification action. A 'resolve' action invokes the notification's +// server-owned remediation; the result message is surfaced to the user by the +// caller via a toast. +export const executeNotificationActionAsync = createAsyncThunk( + 'notifications/executeAction', + async ({ notification, action }) => { + // Ask the backend to resolve the notification (it owns the remediation logic). + if (action?.kind === 'resolve') { + const result = await resolveNotification(notification.id); + + return { message: result?.detail ?? 'Remediation accepted.' }; + } + + return { message: 'Done.' }; + } +); + +// Fetch the active notifications from the backend. On success the payload +// replaces the active set; on failure the existing items are left untouched so +// a transient error never blanks the notification center. +export const fetchNotificationsAsync = createAsyncThunk( + 'notifications/fetch', + async () => { + const data = await fetchNotifications(); + + return data?.notifications ?? []; + } +); + +export const notificationsSlice = createSlice({ + name: 'notifications', + initialState, + reducers: { + // Drop a notification from the active set (e.g. after a successful resolve). + removeNotification: (state, action) => { + state.items = state.items.filter((i) => i.id !== action.payload); + }, + // --- user interactions --- + dismissNotification: (state, action) => { + if (!state.dismissed.includes(action.payload)) { + state.dismissed.push(action.payload); + } + }, + markRead: (state, action) => { + if (!state.readIds.includes(action.payload)) { + state.readIds.push(action.payload); + } + }, + markAllRead: (state) => { + const ids = state.items.map((i) => i.id); + state.readIds = Array.from(new Set([...state.readIds, ...ids])); + }, + }, + extraReducers: (builder) => { + builder + // A manual refresh (dispatched with { manual: true }) shows a spinner while + // in flight and, on success, resyncs to the server's truth by clearing the + // local dismissed set. The silent background poll passes no arg, so it just + // updates the item set without touching dismissals or the spinner. + .addCase(fetchNotificationsAsync.pending, (state, action) => { + if (action.meta.arg?.manual) { + state.refreshing = true; + } + }) + .addCase(fetchNotificationsAsync.fulfilled, (state, action) => { + state.items = action.payload ?? []; + + if (action.meta.arg?.manual) { + state.dismissed = []; + } + + state.refreshing = false; + }) + .addCase(fetchNotificationsAsync.rejected, (state) => { + state.refreshing = false; + }); + }, +}); + +export const { + removeNotification, + dismissNotification, + markRead, + markAllRead, +} = notificationsSlice.actions; + +// --- selectors ------------------------------------------------------------ +const selectItems = (state) => state.notifications.items; +const selectDismissed = (state) => state.notifications.dismissed; +const selectReadIds = (state) => state.notifications.readIds; + +// True while a user-initiated manual refresh is in flight (drives the spinner). +export const getNotificationsRefreshing = (state) => state.notifications.refreshing; + +export const getIsRead = (state, id) => state.notifications.readIds.includes(id); + +// Active = not dismissed, audience-appropriate; sorted by severity (highest +// first), then most-recent first. +export const getActiveNotifications = createSelector( + [selectItems, selectDismissed, getAdminStatus], + (items, dismissed, isAdmin) => + items + .filter((n) => !dismissed.includes(n.id)) + .filter((n) => n.audience !== NOTIFICATION_AUDIENCE.ADMIN || isAdmin) + .slice() + .sort((a, b) => { + const bySeverity = (SEVERITY_PRIORITY[b.severity] ?? 0) - (SEVERITY_PRIORITY[a.severity] ?? 0); + if (bySeverity !== 0) return bySeverity; + return (b.createdAt ?? 0) - (a.createdAt ?? 0); + }) +); + +// Unread badge count (active + not yet read). +export const getUnreadCount = createSelector( + [getActiveNotifications, selectReadIds], + (items, readIds) => items.filter((n) => !readIds.includes(n.id)).length +); + +// The worst (highest-priority) severity among active notifications, used to +// color the bell's attention indicator. Returns a severity string or null. +export const getWorstNotificationSeverity = createSelector( + [getActiveNotifications], + (items) => (items.length ? items[0].severity : null) +); + +export default notificationsSlice.reducer; diff --git a/ui/src/features/notifications/utils/notificationConstants.jsx b/ui/src/features/notifications/utils/notificationConstants.jsx new file mode 100644 index 00000000..880117ec --- /dev/null +++ b/ui/src/features/notifications/utils/notificationConstants.jsx @@ -0,0 +1,42 @@ +// Notification framework constants + mock catalog (UI prototype) +// +// NOTE: The mock catalog below is only used to drive the dev harness so we can +// rapidly prototype the look & feel before the backend providers exist. Each +// entry mirrors the server-side Notification shape we'll produce later. + +export const NOTIFICATION_SEVERITY = { + CRITICAL: 'critical', + WARNING: 'warning', + INFORMATION: 'information', +}; + +export const NOTIFICATION_AUDIENCE = { + ALL: 'all', + ADMIN: 'admin', +}; + +// Relative ranking used for center sorting (highest severity first) +export const SEVERITY_PRIORITY = { + [NOTIFICATION_SEVERITY.CRITICAL]: 3, + [NOTIFICATION_SEVERITY.WARNING]: 2, + [NOTIFICATION_SEVERITY.INFORMATION]: 1, +}; + +// Map notification severity -> MUI palette color (drives color/icon) +export const SEVERITY_COLOR = { + [NOTIFICATION_SEVERITY.CRITICAL]: 'error', + [NOTIFICATION_SEVERITY.WARNING]: 'warning', + [NOTIFICATION_SEVERITY.INFORMATION]: 'info', +}; + +// UI-owned copy: the noun phrase shown in the restart gate for a remediation +// that restarts the service, keyed by the notification's stable id. This is +// presentation, so it lives in the UI (the API stays UI-agnostic); unmapped +// notifications fall back to a generic phrase. +export const RESTART_REASON = { + 'registry-migration': 'the registry migration', + 'registry-dev': 'the registry migration', +}; + +export const DEFAULT_RESTART_REASON = 'the update'; + diff --git a/ui/src/features/notifications/utils/severityMeta.jsx b/ui/src/features/notifications/utils/severityMeta.jsx new file mode 100644 index 00000000..d6cd7580 --- /dev/null +++ b/ui/src/features/notifications/utils/severityMeta.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import ErrorIcon from '@mui/icons-material/Error'; + +import { SEVERITY_COLOR } from './notificationConstants'; + +// Maps an MUI palette color (error | warning | info) to its icon. +const SEVERITY_ICON = { + info: InfoOutlinedIcon, + warning: WarningAmberIcon, + error: ErrorIcon, +}; + +// Resolve the MUI palette color for a notification severity. The returned value +// doubles as a theme palette color key (info | warning | error). +export function getSeverityColor(severity) { + return SEVERITY_COLOR[severity] ?? 'info'; +} + +// Module-scope component so consumers don't create components during render. +// Renders the severity-appropriate icon and forwards any MUI SvgIcon props. +export function SeverityIcon({ severity, ...props }) { + const icon = SEVERITY_ICON[getSeverityColor(severity)] ?? InfoOutlinedIcon; + + return React.createElement(icon, props); +} diff --git a/ui/src/features/restart/ServiceRestartGate.jsx b/ui/src/features/restart/ServiceRestartGate.jsx new file mode 100644 index 00000000..00c2b8c2 --- /dev/null +++ b/ui/src/features/restart/ServiceRestartGate.jsx @@ -0,0 +1,260 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { useMsal } from '@azure/msal-react'; +import { InteractionStatus } from '@azure/msal-browser'; + +import Backdrop from '@mui/material/Backdrop'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; + +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; + +import { SpinnerCircular } from 'spinners-react'; + +import { getRestart, endServiceRestart } from './restartSlice'; +import { fetchStatus } from '../ipam/ipamAPI'; + +// --- Recovery detection --------------------------------------------------- +const POLL_INTERVAL_MS = 5000; // how often to poll /api/status +const SLOW_SEC = 180; // surface the escape hatch (a restart — image pull or zip deploy — realistically takes 1-3 min) +const TIMEOUT_SEC = 300; // declare the service unreachable after this + +// Full-screen blocking overlay shown while the backend service restarts (e.g. +// after a registry migration or an in-app update). The live SPA stays in memory +// and drives the wait, then reloads to pick up the new build. +// +// Recovery detection polls /api/status and keys off a changed start time +// (restart confirmation) and, for updates, a changed version. +function ServiceRestartGate() { + const dispatch = useDispatch(); + const { active, reason, verify } = useSelector(getRestart); + const { inProgress } = useMsal(); + + const [phase, setPhase] = React.useState('restarting'); + const [elapsed, setElapsed] = React.useState(0); + const [failureReason, setFailureReason] = React.useState(null); + const baselineRef = React.useRef(null); + const inProgressRef = React.useRef(InteractionStatus.None); + + // Keep the latest MSAL interaction status readable inside the poll interval + // without re-subscribing the poller when it changes. + React.useEffect(() => { + inProgressRef.current = inProgress; + }, [inProgress]); + + // Reset + elapsed ticker whenever a restart begins. + React.useEffect(() => { + if (!active) { + return undefined; + } + + setPhase('restarting'); + setElapsed(0); + setFailureReason(null); + baselineRef.current = null; + + const ticker = setInterval(() => setElapsed((value) => value + 1), 1000); + + return () => clearInterval(ticker); + }, [active]); + + // Recovery detection: poll /api/status and key off a changed start time + // (restart confirmation) and, when requested, a changed version (update verify). + React.useEffect(() => { + if (!active) { + return undefined; + } + + let cancelled = false; + let sawDown = false; + + const poll = async () => { + try { + const status = await fetchStatus(); + + if (cancelled) { + return; + } + + // Capture the pre-restart baseline on the first successful read. + if (!baselineRef.current) { + baselineRef.current = { + startTime: status.start_time ?? null, + version: status.version ?? null, + }; + return; + } + + const baseline = baselineRef.current; + const restarted = sawDown || (status.start_time ?? null) !== baseline.startTime; + + if (!restarted) { + return; + } + + // Recovered. For an update, require the version to have advanced too. + if (verify === 'version' && (status.version ?? null) === baseline.version) { + setFailureReason('stale-version'); + setPhase('failed'); + return; + } + + // Don't reload mid-interaction: interrupting an MSAL redirect orphans the + // interaction lock. Recovery stays true, so the next poll retries when idle. + setPhase('reloading'); + + if (inProgressRef.current === InteractionStatus.None) { + window.location.reload(); + } + } catch { + // Unreachable window = the service is cycling; note it and keep waiting. + sawDown = true; + + if (!cancelled) { + setPhase('restarting'); + } + } + }; + + poll(); + const poller = setInterval(poll, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + clearInterval(poller); + }; + }, [active, verify]); + + // Real timeout: we never stop polling on the user's behalf, but past the + // timeout the prolonged render state acknowledges something may be wrong and + // offers a deliberate manual reload (see below). No phase change here. + + if (!active) { + return null; + } + + const handleManualReload = () => { + window.location.reload(); + }; + + const phaseCopy = { + restarting: { + title: 'Applying changes\u2026', + subtitle: `Azure IPAM is restarting to apply ${reason}. This can take a few minutes \u2014 please keep this window open.`, + }, + verifying: { + title: 'Verifying the update\u2026', + subtitle: 'The service is back online. Confirming the new version is running.', + }, + reloading: { + title: 'Reloading\u2026', + subtitle: 'Loading the latest version of Azure IPAM.', + }, + }; + + const failureCopy = { + 'stale-version': { + title: 'The update may not have applied', + subtitle: + 'The service restarted but is still reporting the previous version. The update may have failed to install correctly.', + }, + }; + + const isFailed = phase === 'failed'; + const slowThreshold = SLOW_SEC; + const timeoutThreshold = TIMEOUT_SEC; + + // Past the timeout we keep polling on the user's behalf, but acknowledge that + // something may be wrong and offer a deliberate manual "try it anyway". + const prolonged = phase === 'restarting' && elapsed >= timeoutThreshold; + + // Calm, time-keyed reassurance while restarting — phrases at intervals instead + // of a raw seconds counter ticking up. The slow escape hatch takes over later. + let waitPhrase = null; + + if (phase === 'restarting' && elapsed < slowThreshold) { + if (elapsed >= 90) { + waitPhrase = 'Still working — this can take a couple of minutes.'; + } else if (elapsed >= 30) { + waitPhrase = 'Hang tight while the service comes back onlineâ€Ļ'; + } + } + + return ( + theme.zIndex.tooltip + 100, + color: '#fff', + flexDirection: 'column', + textAlign: 'center', + px: 3, + backgroundColor: 'rgba(15, 23, 33, 0.92)', + }} + > + {isFailed ? ( + + ) : ( + + )} + + + {isFailed ? failureCopy[failureReason]?.title : phaseCopy[phase]?.title} + + + {isFailed ? failureCopy[failureReason]?.subtitle : phaseCopy[phase]?.subtitle} + + + {phase === 'restarting' && waitPhrase && ( + + {waitPhrase} + + )} + + {phase === 'restarting' && elapsed >= slowThreshold && !prolonged && ( + + This is taking longer than usual. We'll bring you back automatically as soon as Azure IPAM is online. + + )} + + {prolonged && ( + + + This is taking longer than expected — something may have gone wrong. We'll keep + checking on your behalf. If you believe Azure IPAM is already back, you can try reloading. + + + + + + + )} + + {isFailed && ( + + + + + )} + + ); +} + +export default ServiceRestartGate; diff --git a/ui/src/features/restart/restartSlice.jsx b/ui/src/features/restart/restartSlice.jsx new file mode 100644 index 00000000..8d07b65d --- /dev/null +++ b/ui/src/features/restart/restartSlice.jsx @@ -0,0 +1,35 @@ +import { createSlice } from '@reduxjs/toolkit'; + +// Drives the full-screen service-restart gate. The gate component owns the +// timeline; this slice just records that a restart is in progress and how it +// should behave (operation type). +const initialState = { + active: false, + reason: 'the update', // human-readable noun phrase shown in the overlay (display only) + // Optional post-recovery verification for this restart. null = the shared + // "restart and come back online" flow is sufficient (e.g. migrate). A value + // such as 'version' adds a verification step (e.g. update). New reasons just + // supply (or omit) a verify check — no per-reason code paths. + verify: null, +}; + +export const restartSlice = createSlice({ + name: 'restart', + initialState, + reducers: { + beginServiceRestart: (state, action) => { + state.active = true; + state.reason = action.payload?.reason ?? 'the update'; + state.verify = action.payload?.verify ?? null; + }, + endServiceRestart: (state) => { + state.active = false; + }, + }, +}); + +export const { beginServiceRestart, endServiceRestart } = restartSlice.actions; + +export const getRestart = (state) => state.restart; + +export default restartSlice.reducer; 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..4a7ebd71 100644 --- a/ui/src/features/tools/generator/generator.jsx +++ b/ui/src/features/tools/generator/generator.jsx @@ -5,10 +5,11 @@ import { useNavigate } from "react-router"; import { useSnackbar } from "notistack"; -import { isEqual, sortBy, pick } from "lodash"; +import { isEqual, sortBy, pick } from "lodash-es"; 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); @@ -334,7 +332,6 @@ const Generator = () => { { {...params} label={ showSubnets ? "Subscription" : "Space" } placeholder={ `Please Select ${showSubnets ? "Subscription" : "Space"}...` } - InputProps={{ - ...params.InputProps, - endAdornment: ( - - {!spaces ? : null} - {params.InputProps.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + endAdornment: ( + + {!spaces ? : null} + {params.slotProps.input.endAdornment} + + ), + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • { showSubnets ? `${option.name} (${option.subscription_id})` : option.name }
  • ); }} + slots={{ + popper: MyPopper + }} /> { {...params} label={ showSubnets ? "Network" : "Block" } placeholder={ `Please Select ${showSubnets ? "Network" : "Block"}...` } - InputProps={{ - ...params.InputProps + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input + } }} /> )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -561,12 +569,6 @@ const Generator = () => { value={selectedMask} onChange={(event, newValue) => setSelectedMask(newValue)} sx={{ width: '5ch' }} - ListboxProps={{ - style: { - maxHeight: "15rem" - }, - position: "bottom-start" - }} renderInput={(params) => ( { placeholder="Mask" /> )} + slotProps={{ + listbox: { + style: { + maxHeight: "15rem" + }, + position: "bottom-start" + } + }} /> { vertical: 'top', horizontal: 'left', }} - MenuListProps={{ - 'aria-labelledby': 'basic-button', + slotProps={{ + list: { + 'aria-labelledby': 'basic-button', + } }} > @@ -675,31 +687,33 @@ const Generator = () => { label="Next Available" value={ nextAvailable || "" } variant="outlined" - InputProps={{ - endAdornment: - - - - }} sx={{ width: '13ch', '& .MuiOutlinedInput-root': { paddingRight: 'unset', } }} + slotProps={{ + input: { + endAdornment: + + + + } + }} /> - Generate - +
    diff --git a/ui/src/features/tools/planner/planner.jsx b/ui/src/features/tools/planner/planner.jsx index 3c0d108c..bed6dfee 100644 --- a/ui/src/features/tools/planner/planner.jsx +++ b/ui/src/features/tools/planner/planner.jsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useSelector } from "react-redux"; import { ThemeProvider, createTheme, styled } from "@mui/material/styles"; -import { find,isEqual, orderBy } from "lodash"; +import { find,isEqual, orderBy } from "lodash-es"; import { Box, @@ -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]); @@ -266,14 +280,18 @@ const Planner = () => { {...params} label="Virtual Network" placeholder={showAll ? "By Subscription" : "By Space ➜ Block"} - InputProps={{ - ...params.InputProps, - endAdornment: ( - - {!vNetData ? : null} - {params.InputProps.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + + input: { + ...params.slotProps.input, + endAdornment: ( + + {!vNetData ? : null} + {params.slotProps.input.endAdornment} + + ), + } }} /> )} @@ -285,12 +303,12 @@ const Planner = () => { )} renderOption={(props, option) => { return ( -
  • +
  • {option.name}
  • ); }} - componentsProps={{ + slotProps={{ paper: { sx: { width: 'fit-content' @@ -314,10 +332,12 @@ const Planner = () => { onChange={(event) => setSelectedPrefix(event.target.value)} sx={{ width: '22ch' }} MenuProps={{ - PaperProps: { - style: { - maxHeight: 36 * 10, - } + slotProps: { + paper: { + style: { + maxHeight: 36 * 10, + } + }, }, }} > @@ -332,7 +352,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(); + + dragStateRef.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 (!dragStateRef.current || !paperRef.current) return; + + const { startX, startY, offsetX, offsetY, parentWidth, parentHeight, elWidth, elHeight } = + dragStateRef.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(() => { + dragStateRef.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..2c190d3a --- /dev/null +++ b/ui/src/global/grids/ConfigureGrid/ConfigureGrid.jsx @@ -0,0 +1,260 @@ +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.use(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..534867d7 --- /dev/null +++ b/ui/src/global/grids/DataGrid/DataGrid.jsx @@ -0,0 +1,1056 @@ +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-es'; +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.use(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); + } + // gridRef is stable; reading current is a deliberate fallback pattern + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnDefs]); + + // eslint-disable-next-line react-hooks/refs + 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.use(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.use(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(); + } + } + } + }} + 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.use(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 initialSelectionAppliedRef = 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 viewSettingsAppliedRef = useRef(false); + + const onGridReady = useCallback((params) => { + const { api } = params; + + // Apply saved view settings when grid is ready + if (viewSetting && viewSetting.columnState && !viewSettingsAppliedRef.current) { + try { + api.applyColumnState({ + state: viewSetting.columnState, + applyOrder: true, + }); + + viewSettingsAppliedRef.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 && !initialSelectionAppliedRef.current) { + requestAnimationFrame(() => { + api.deselectAll(); + + api.forEachNode((node) => { + const rowId = node.data?.[idProperty]; + if (rowId !== undefined && rowId !== null && initialRowSelection[rowId]) { + node.setSelected(true); + } + }); + + initialSelectionAppliedRef.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(() => { + initialSelectionAppliedRef.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 || viewSettingsAppliedRef.current) return; + + // Only apply if we have the new AG Grid format + if (viewSetting.columnState) { + try { + gridApi.applyColumnState({ + state: viewSetting.columnState, + applyOrder: true, + }); + + viewSettingsAppliedRef.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); + } + }); + + initialSelectionAppliedRef.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..3c7148a1 100644 --- a/ui/src/index.jsx +++ b/ui/src/index.jsx @@ -1,9 +1,9 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; +import { Box, Typography } from '@mui/material'; import { store } from './app/store'; import App from './App'; -import reportWebVitals from './reportWebVitals'; import './index.css'; import { PublicClientApplication } from "@azure/msal-browser"; @@ -13,19 +13,72 @@ 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( - - - - - - - -); - -// 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(); +/** + * 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]); + } + } + + root.render( + + + + + + + + ); +}).catch((error) => { + // MSAL failed to initialize (misconfiguration, blocked storage access, + // third-party cookie restrictions, etc.). Log the error so it is + // diagnosable instead of surfacing only as an unhandled promise rejection. + console.error("MSAL initialization failed:", error); + + // Do not render fallback UI inside MSAL's hidden iframe. + if (isInHiddenIframe) { + return; + } + + // Render a minimal fallback so the user is not left with a blank page. + root.render( + + + + Unable to start Azure IPAM + + + The authentication service could not be initialized. Please refresh the + page or try again later. If the problem persists, contact your administrator. + + + + ); +}); 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..4dfa2b61 --- /dev/null +++ b/ui/src/msal/authHandler.jsx @@ -0,0 +1,105 @@ +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 AuthHandler() { + const { instance, inProgress } = useMsal(); + const [pendingReauth, setPendingReauth] = React.useState(null); + const redirectPendingRef = React.useRef(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); + } + + redirectPendingRef.current = false; + setPendingReauth(null); + 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; + } + + // A silent token acquisition failed and needs interaction. Trigger a + // single re-auth redirect via the effect below. loginRedirect(loginRequest) + // re-consents every scope (API + Graph), so it covers any failed request. + redirectPendingRef.current = false; + setPendingReauth({ error }); + } + }); + + return () => { + if (callbackId) { + instance.removeEventCallback(callbackId); + } + }; + }, [instance]); + + React.useEffect(() => { + if (!pendingReauth) { + return; + } + + if (inProgress !== InteractionStatus.None || redirectPendingRef.current) { + return; + } + + redirectPendingRef.current = true; + + instance.loginRedirect(loginRequest).catch((error) => { + console.error("Re-authentication redirect failed", error); + + // Allow a retry once the in-progress interaction clears. A fresh object + // changes identity so this effect re-runs. + redirectPendingRef.current = false; + setPendingReauth((current) => (current ? { ...current } : current)); + }); + }, [pendingReauth, 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/src/utils/snackbar.jsx b/ui/src/utils/snackbar.jsx index 7c7bd473..3ad8688e 100644 --- a/ui/src/utils/snackbar.jsx +++ b/ui/src/utils/snackbar.jsx @@ -1,11 +1,4 @@ -import { useSnackbar } from 'notistack' - -let useSnackbarRef; -export const SnackbarUtilsConfigurator = () => { - useSnackbarRef = useSnackbar(); - - return null; -} +import { enqueueSnackbar } from 'notistack' const SnackbarUtils = { success(msg) { @@ -21,7 +14,7 @@ const SnackbarUtils = { this.toast(msg, 'error'); }, toast(msg, variant = 'default') { - useSnackbarRef.enqueueSnackbar(msg, { variant }); + enqueueSnackbar(msg, { variant }); } } 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", diff --git a/update/main.bicep b/update/main.bicep new file mode 100644 index 00000000..21287e19 --- /dev/null +++ b/update/main.bicep @@ -0,0 +1,92 @@ +// Global parameters +targetScope = 'subscription' + +// Existing Azure IPAM deployment details (discovered + verified by update.ps1 before deployment) +type resourceObject = { + appServiceName: string + appServiceRG: string + appServicePlanId: string + managedIdentityId: string + managedIdentityClientId: string + cosmosDbUri: string + databaseName: string + containerName: string + keyVaultUri: string + linuxFxVersion: string + isFunction: bool + deployAsContainer: bool + privateAcr: bool + privateAcrUri: string + acrUseManagedIdentity: bool + runFromPackage: bool +} + +@description('Deployment Location') +param location string = deployment().location + +@description('Azure Cloud Enviroment') +param azureCloud string = 'AZURE_PUBLIC' + +@description('Replicate production vNet integration onto the slot (set false to create a bare slot)') +param replicateVnet bool = true + +@description('Existing Azure IPAM Resource Details') +param resourceDetails resourceObject + +// App Service staging slot (additive: creates the slot only, production site is untouched) +module appServiceSlot './modules/appServiceSlot.bicep' = if (!resourceDetails.isFunction) { + name: 'appServiceSlotModule' + scope: resourceGroup(resourceDetails.appServiceRG) + params: { + location: location + azureCloud: azureCloud + appServiceName: resourceDetails.appServiceName + appServicePlanId: resourceDetails.appServicePlanId + managedIdentityId: resourceDetails.managedIdentityId + managedIdentityClientId: resourceDetails.managedIdentityClientId + cosmosDbUri: resourceDetails.cosmosDbUri + databaseName: resourceDetails.databaseName + containerName: resourceDetails.containerName + keyVaultUri: resourceDetails.keyVaultUri + deployAsContainer: resourceDetails.deployAsContainer + privateAcr: resourceDetails.privateAcr + privateAcrUri: resourceDetails.privateAcrUri + acrUseManagedIdentity: resourceDetails.acrUseManagedIdentity + runFromPackage: resourceDetails.runFromPackage + linuxFxVersion: resourceDetails.linuxFxVersion + replicateVnet: replicateVnet + } +} + +// Function App staging slot (additive: creates the slot only, production site is untouched) +module functionAppSlot './modules/functionAppSlot.bicep' = if (resourceDetails.isFunction) { + name: 'functionAppSlotModule' + scope: resourceGroup(resourceDetails.appServiceRG) + params: { + location: location + azureCloud: azureCloud + functionAppName: resourceDetails.appServiceName + functionPlanId: resourceDetails.appServicePlanId + managedIdentityId: resourceDetails.managedIdentityId + managedIdentityClientId: resourceDetails.managedIdentityClientId + cosmosDbUri: resourceDetails.cosmosDbUri + databaseName: resourceDetails.databaseName + containerName: resourceDetails.containerName + keyVaultUri: resourceDetails.keyVaultUri + deployAsContainer: resourceDetails.deployAsContainer + privateAcr: resourceDetails.privateAcr + privateAcrUri: resourceDetails.privateAcrUri + acrUseManagedIdentity: resourceDetails.acrUseManagedIdentity + runFromPackage: resourceDetails.runFromPackage + linuxFxVersion: resourceDetails.linuxFxVersion + replicateVnet: replicateVnet + } +} + +// Outputs +output stagingSlotHostName string = resourceDetails.isFunction + ? functionAppSlot!.outputs.stagingSlotHostName + : appServiceSlot!.outputs.stagingSlotHostName +output vnetReplicated bool = resourceDetails.isFunction + ? functionAppSlot!.outputs.vnetReplicated + : appServiceSlot!.outputs.vnetReplicated diff --git a/update/modules/appServiceSlot.bicep b/update/modules/appServiceSlot.bicep new file mode 100644 index 00000000..6bffe058 --- /dev/null +++ b/update/modules/appServiceSlot.bicep @@ -0,0 +1,156 @@ +@description('App Service Name (existing production site)') +param appServiceName string + +@description('App Service Plan Resource ID') +param appServicePlanId string + +@description('Managed Identity Id') +param managedIdentityId string + +@description('Managed Identity ClientId') +param managedIdentityClientId string + +@description('Deployment Location') +param location string = resourceGroup().location + +@description('Azure Cloud Enviroment') +param azureCloud string = 'AZURE_PUBLIC' + +@description('CosmosDB URI') +param cosmosDbUri string + +@description('CosmosDB Database Name') +param databaseName string + +@description('CosmosDB Container Name') +param containerName string + +@description('KeyVault URI') +param keyVaultUri string + +@description('Flag to Deploy IPAM as a Container') +param deployAsContainer bool + +@description('Flag to Deploy Private Container Registry') +param privateAcr bool + +@description('Uri for Private Container Registry') +param privateAcrUri string + +@description('Whether the app authenticates to ACR with a managed identity (mirrored from production)') +param acrUseManagedIdentity bool = false + +@description('Flag to Run from Package (build-restricted clouds)') +param runFromPackage bool = false + +@description('Current production LinuxFxVersion (runtime stack / container image) to mirror onto the slot') +param linuxFxVersion string + +@description('Replicate production VNet integration onto the slot (set false to create a bare slot)') +param replicateVnet bool = true + +// Existing production site (never modified by this template) +resource appService 'Microsoft.Web/sites@2022-03-01' existing = { + name: appServiceName +} + +// Live production app settings, read at deploy time to preserve any user-added extras +var liveAppSettings = list('${appService.id}/config/appsettings', '2022-03-01').properties + +// Canonical baseline keys that Azure IPAM owns and enforces. +// IMPORTANT: keep in sync with deploy/modules/appService.bicep (appServiceSiteConfig). +// Any user settings beyond these keys are preserved via the union() below. +var canonicalBaseline = union( + { + AZURE_ENV: azureCloud + COSMOS_URL: cosmosDbUri + DATABASE_NAME: databaseName + CONTAINER_NAME: containerName + MANAGED_IDENTITY_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + UI_APP_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + ENGINE_APP_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + ENGINE_APP_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + TENANT_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + KEYVAULT_URL: keyVaultUri + WEBSITE_HEALTHCHECK_MAXPINGFAILURES: '2' + }, + deployAsContainer + ? { + WEBSITE_ENABLE_SYNC_UPDATE_SITE: 'true' + DOCKER_REGISTRY_SERVER_URL: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + } + : runFromPackage + ? { + WEBSITE_RUN_FROM_PACKAGE: '1' + } + : { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + } +) + +// Merge: enforce baseline key PRESENCE only. Live values ALWAYS win; the canonical +// baseline only backfills baseline keys that are entirely MISSING. This never overwrites +// values users have customized over time (e.g. version-pinned Key Vault secret references). +var mergedSettings = union(canonicalBaseline, liveAppSettings) + +// Replicate user VNet integration (regional) onto the slot, if present on production +var prodSubnetId = appService.properties.?virtualNetworkSubnetId ?? '' + +// Staging slot (created dormant; started on demand for slot-based upgrades/swaps) +resource appServiceStagingSlot 'Microsoft.Web/sites/slots@2022-03-01' = { + name: 'staging' + parent: appService + location: location + kind: deployAsContainer ? 'app,linux,container' : 'app,linux' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } + } + properties: { + enabled: false + httpsOnly: true + serverFarmId: appServicePlanId + keyVaultReferenceIdentity: managedIdentityId + virtualNetworkSubnetId: (replicateVnet && !empty(prodSubnetId)) ? prodSubnetId : null + siteConfig: { + acrUseManagedIdentityCreds: acrUseManagedIdentity + acrUserManagedIdentityID: acrUseManagedIdentity ? managedIdentityClientId : null + alwaysOn: true + linuxFxVersion: linuxFxVersion + appCommandLine: !deployAsContainer ? 'bash ./init.sh 8000' : null + healthCheckPath: '/api/status' + } + } +} + +// Merged app settings (object map) applied via the dedicated appsettings config +resource appServiceStagingSlotAppSettings 'Microsoft.Web/sites/slots/config@2022-03-01' = { + name: 'appsettings' + parent: appServiceStagingSlot + properties: mergedSettings +} + +resource appServiceStagingSlotLogs 'Microsoft.Web/sites/slots/config@2022-03-01' = { + name: 'logs' + parent: appServiceStagingSlot + properties: { + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 50 + } + } + } +} + +output stagingSlotHostName string = appServiceStagingSlot.properties.defaultHostName +output vnetReplicated bool = replicateVnet && !empty(prodSubnetId) diff --git a/update/modules/functionAppSlot.bicep b/update/modules/functionAppSlot.bicep new file mode 100644 index 00000000..914fe01d --- /dev/null +++ b/update/modules/functionAppSlot.bicep @@ -0,0 +1,172 @@ +@description('Function App Name (existing production site)') +param functionAppName string + +@description('Function App Plan Resource ID') +param functionPlanId string + +@description('Managed Identity Id') +param managedIdentityId string + +@description('Managed Identity ClientId') +param managedIdentityClientId string + +@description('Deployment Location') +param location string = resourceGroup().location + +@description('Azure Cloud Enviroment') +param azureCloud string = 'AZURE_PUBLIC' + +@description('CosmosDB URI') +param cosmosDbUri string + +@description('CosmosDB Database Name') +param databaseName string + +@description('CosmosDB Container Name') +param containerName string + +@description('KeyVault URI') +param keyVaultUri string + +@description('Flag to Deploy IPAM as a Container') +param deployAsContainer bool + +@description('Flag to Deploy Private Container Registry') +param privateAcr bool + +@description('Uri for Private Container Registry') +param privateAcrUri string + +@description('Whether the app authenticates to ACR with a managed identity (mirrored from production)') +param acrUseManagedIdentity bool = false + +@description('Flag to Run from Package (build-restricted clouds)') +param runFromPackage bool = false + +@description('Current production LinuxFxVersion (runtime stack / container image) to mirror onto the slot') +param linuxFxVersion string + +@description('Replicate production VNet integration onto the slot (set false to create a bare slot)') +param replicateVnet bool = true + +// Existing production site (never modified by this template) +resource functionApp 'Microsoft.Web/sites@2022-03-01' existing = { + name: functionAppName +} + +// Live production app settings, read at deploy time to preserve user-added extras +// AND deployment-owned secrets (storage connection string, content share, App Insights key) +var liveAppSettings = list('${functionApp.id}/config/appsettings', '2022-03-01').properties + +// Canonical baseline keys that Azure IPAM owns and enforces. +// IMPORTANT: keep in sync with deploy/modules/functionApp.bicep (functionAppSiteConfig). +// Storage/content-share/App Insights settings are intentionally NOT enforced here; they are +// preserved as-is from the live site. Any user settings beyond these keys are also preserved. +var canonicalBaseline = union( + { + AZURE_ENV: azureCloud + COSMOS_URL: cosmosDbUri + DATABASE_NAME: databaseName + CONTAINER_NAME: containerName + MANAGED_IDENTITY_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/IDENTITY-ID/)' + UI_APP_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/UI-ID/)' + ENGINE_APP_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-ID/)' + ENGINE_APP_SECRET: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/ENGINE-SECRET/)' + TENANT_ID: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/TENANT-ID/)' + KEYVAULT_URL: keyVaultUri + FUNCTIONS_EXTENSION_VERSION: '~4' + WEBSITE_HEALTHCHECK_MAXPINGFAILURES: '2' + }, + deployAsContainer + ? { + DOCKER_REGISTRY_SERVER_URL: privateAcr ? 'https://${privateAcrUri}' : 'https://index.docker.io/v1' + WEBSITES_ENABLE_APP_SERVICE_STORAGE: 'false' + } + : runFromPackage + ? { + FUNCTIONS_WORKER_RUNTIME: 'python' + WEBSITE_RUN_FROM_PACKAGE: '1' + } + : { + FUNCTIONS_WORKER_RUNTIME: 'python' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + } +) + +// Merge: enforce baseline key PRESENCE only. Live values ALWAYS win; the canonical +// baseline only backfills baseline keys that are entirely MISSING. This never overwrites +// values users have customized over time (e.g. version-pinned Key Vault secret references). +var mergedSettings = union(canonicalBaseline, liveAppSettings) + +// Replicate user VNet integration (regional) onto the slot, if present on production +var prodSubnetId = functionApp.properties.?virtualNetworkSubnetId ?? '' + +// Staging slot (created dormant; started on demand for slot-based upgrades/swaps) +resource functionAppStagingSlot 'Microsoft.Web/sites/slots@2022-03-01' = { + name: 'staging' + parent: functionApp + location: location + kind: deployAsContainer ? 'functionapp,linux,container' : 'functionapp,linux' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } + } + properties: { + enabled: false + httpsOnly: true + serverFarmId: functionPlanId + keyVaultReferenceIdentity: managedIdentityId + virtualNetworkSubnetId: (replicateVnet && !empty(prodSubnetId)) ? prodSubnetId : null + siteConfig: { + acrUseManagedIdentityCreds: acrUseManagedIdentity + acrUserManagedIdentityID: acrUseManagedIdentity ? managedIdentityClientId : null + linuxFxVersion: linuxFxVersion + healthCheckPath: '/api/status' + } + } +} + +// Merged app settings (object map) applied via the dedicated appsettings config +resource functionAppStagingSlotAppSettings 'Microsoft.Web/sites/slots/config@2022-03-01' = { + name: 'appsettings' + parent: functionAppStagingSlot + properties: mergedSettings +} + +resource functionAppStagingSlotLogs 'Microsoft.Web/sites/slots/config@2022-03-01' = { + name: 'logs' + parent: functionAppStagingSlot + properties: { + detailedErrorMessages: { + enabled: true + } + failedRequestsTracing: { + enabled: true + } + httpLogs: { + fileSystem: { + enabled: true + retentionInDays: 7 + retentionInMb: 50 + } + } + } +} + +// Mark content-share settings as slot-sticky so a future swap never crosses the file share. +// Container function apps don't use a content share, so this only applies to non-container plans. +resource functionAppSlotConfigNames 'Microsoft.Web/sites/config@2022-03-01' = if (!deployAsContainer) { + name: 'slotConfigNames' + parent: functionApp + properties: { + appSettingNames: [ + 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + 'WEBSITE_CONTENTSHARE' + ] + } +} + +output stagingSlotHostName string = functionAppStagingSlot.properties.defaultHostName +output vnetReplicated bool = replicateVnet && !empty(prodSubnetId) diff --git a/update/update.ps1 b/update/update.ps1 new file mode 100644 index 00000000..edceb296 --- /dev/null +++ b/update/update.ps1 @@ -0,0 +1,1219 @@ +############################################################################################################### +## +## Azure IPAM ZIP Deploy Updater Script +## +############################################################################################################### + +# Set minimum version requirements +#Requires -Version 7.2 +#Requires -Modules @{ ModuleName="Az.Accounts"; ModuleVersion="2.13.0" } +#Requires -Modules @{ ModuleName="Az.Functions"; ModuleVersion="4.0.6" } +#Requires -Modules @{ ModuleName="Az.Websites"; ModuleVersion="3.1.1" } +#Requires -Modules @{ ModuleName="Az.Resources"; ModuleVersion="6.16.0" } + +# Intake and set global parameters +param( + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true)] + [string] + $AppName, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $true)] + [string] + $ResourceGroupName, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory=$false)] + [string] + $GitHubUserName = "Azure", + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory=$false)] + [string] + $GitHubRepoName = "ipam", + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory=$false)] + [ValidateScript({ + $IndexOfInvalidChar = $_.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) + if (-Not ($IndexOfInvalidChar -eq -1)) { + throw [System.ArgumentException]::New("The 'ZipFileName' argument contains one or more invalid characters.") + } + if(-Not ($_ -match "(\.zip)")) { + throw [System.ArgumentException]::New("The 'ZipFileName' argument must be of type zip.") + } + return $true + })] + [string] + $ZipFileName = "ipam.zip", + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory=$false)] + [ValidateScript({ + if(-Not ($_ | Get-Item) ) { + throw [System.ArgumentException]::New("AssetFolder does not exist, please provide a pre-existing folder.") + } + return $true + })] + [System.IO.DirectoryInfo] + $AssetFolder, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false)] + [ValidateScript({ + if(-Not ($_ | Test-Path) ) { + throw [System.ArgumentException]::New("Target file or does not exist.") + } + if(-Not ($_ | Test-Path -PathType Leaf) ) { + throw [System.ArgumentException]::New("The 'ZipFilePath' argument must be a file, folder paths are not allowed.") + } + if($_ -notmatch "(\.zip)") { + throw [System.ArgumentException]::New("The file specified in the 'ZipFilePath' argument must be of type zip.") + } + return $true + })] + [System.IO.FileInfo] + $ZipFilePath, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false)] + [switch] + $SkipInfraUpdate, + + [Parameter(ValueFromPipelineByPropertyName = $true, + Mandatory = $false)] + [switch] + $Force +) + +# Root Directory +$ROOT_DIR = (Get-Item $($MyInvocation.MyCommand.Path)).Directory.Parent.FullName + +# Minimum Required Azure CLI Version +$MIN_AZ_CLI_VER = [System.Version]'2.35.0' + +# Azure IPAM Public ACR (element [0] is the current/preferred registry; remaining values are legacy registries retained for backward-compatible detection) +$IPAM_PUBLIC_ACR = @("registry.azureipam.com", "azureipam.azurecr.io") + +# Set preference variables +$ErrorActionPreference = "Stop" + +# Hide Azure PowerShell SDK Warnings +$Env:SuppressAzurePowerShellBreakingChangeWarnings = $true + +# Hide Azure PowerShell SDK & Azure CLI Survey Prompts +$Env:AzSurveyMessage = $false +$Env:AZURE_CORE_SURVEY_MESSAGE = $false + +# Set Log File Location +$logPath = Join-Path -Path $ROOT_DIR -ChildPath "logs" +New-Item -ItemType Directory -Path $logpath -Force | Out-Null + +$updateLog = Join-Path -Path $logPath -ChildPath "update_$(get-date -format `"yyyyMMddhhmmsstt`").log" +$errorLog = Join-Path -Path $logPath -ChildPath "error_$(get-date -format `"yyyyMMddhhmmsstt`").log" + +$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 Write-Section { + Param( + [Parameter(Mandatory = $true)] + [string]$Title + ) + + Write-Host + Write-Host "=====================================================" -ForegroundColor Blue + Write-Host $Title -ForegroundColor Yellow + Write-Host "=====================================================" -ForegroundColor Blue +} + +Function Get-BuildLogs { + Param( + [Parameter(Mandatory=$true)] + [string]$SubscriptionId, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [string]$RegistryName, + [Parameter(Mandatory=$true)] + [string]$BuildId + ) + + $msArmMap = @{ + AZURE_PUBLIC = "management.azure.com" + AZURE_US_GOV = "management.usgovcloudapi.net" + AZURE_US_GOV_SECRET = "management.azure.microsoft.scloud" + AZURE_GERMANY = "management.microsoftazure.de" + AZURE_CHINA = "management.chinacloudapi.cn" + }; + + $accessToken = Get-AccessToken + + $response = Invoke-RestMethod ` + -Method POST ` + -Uri "https://$($msArmMap[$AzureCloud])/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.ContainerRegistry/registries/$RegistryName/runs/$BuildId/listLogSasUrl?api-version=2019-04-01" ` + -Authentication Bearer ` + -Token $accessToken + + $logLink = $response.logLink + + $logs = Invoke-RestMethod ` + -Method GET ` + -Uri $logLink + + return $logs +} + +Function Set-HealthCheck { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName + ) + + Set-AzResource ` + -ResourceGroupName $ResourceGroupName ` + -ResourceType "Microsoft.Web/sites/config" ` + -ResourceName "${AppName}/web" ` + -ApiVersion "2023-12-01" ` + -Properties @{ healthCheckPath = "/api/status" } ` + -Force ` + | Out-Null + + $existing = @{} + + $site = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $AppName + $site.SiteConfig.AppSettings | ForEach-Object { $existing[$_.Name] = $_.Value } + $existing["WEBSITE_HEALTHCHECK_MAXPINGFAILURES"] = "2" + + Set-AzWebApp ` + -ResourceGroupName $ResourceGroupName ` + -Name $AppName ` + -AppSettings $existing ` + | Out-Null +} + +Function Restart-IpamApp { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$false)] + [switch]$Function + ) + + $restartRetries = 5 + $restartSuccess = $False + + Write-Host "🔄 Restarting application..." -ForegroundColor Cyan + + do { + try { + if ($Function) { + Restart-AzFunctionApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable restartErr ` + -ErrorAction SilentlyContinue ` + -Force ` + | Out-Null + } else { + Restart-AzWebApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable restartErr ` + -ErrorAction SilentlyContinue ` + | Out-Null + } + + if ($restartErr) { + throw $restartErr + } + + $restartSuccess = $True + Write-Host " ✅ Application restarted successfully" -ForegroundColor Green + } catch { + if($restartRetries -gt 0) { + Write-Host " âš ī¸ Restart failed, retrying..." -ForegroundColor Yellow + $restartRetries-- + } else { + Write-Host " ❌ Unable to restart application!" -ForegroundColor Red + throw $_ + } + } + } while ($restartSuccess -eq $False -and $restartRetries -gt 0) +} + +Function Get-ZipFile { + Param( + [Parameter(Mandatory=$true)] + [string]$GitHubUserName, + [Parameter(Mandatory=$true)] + [string]$GitHubRepoName, + [Parameter(Mandatory=$true)] + [string]$ZipFileName, + [Parameter(Mandatory=$true)] + [System.IO.DirectoryInfo]$AssetFolder + ) + + $ZipFilePath = Join-Path -Path $AssetFolder.FullName -ChildPath $ZipFileName + + try { + $GitHubURL = "https://api.github.com/repos/$GitHubUserName/$GitHubRepoName/releases/latest" + + Write-Host "🔍 Target GitHub repo: " -ForegroundColor Cyan -NoNewline + Write-Host "$GitHubUserName/$GitHubRepoName" -ForegroundColor White + Write-Host "🔍 Fetching latest release download URL..." -ForegroundColor Cyan -NoNewline + + $GHResponse = Invoke-WebRequest -Method GET -Uri $GitHubURL + $JSONResponse = $GHResponse.Content | ConvertFrom-Json + $AssetList = $JSONResponse.assets + $Asset = $AssetList | Where-Object { $_.name -eq $ZipFileName } + $DownloadURL = $Asset.browser_download_url + + Write-Host " ✅ Found" -ForegroundColor Green + Write-Host "🔍 Downloading ZIP archive to " -ForegroundColor Cyan -NoNewline + Write-Host $ZipFilePath -ForegroundColor White + + Invoke-WebRequest -Uri $DownloadURL -OutFile $ZipFilePath + } catch { + Write-Host " ❌ Failed" -ForegroundColor Red + Write-Host " ❌ Unable to download ZIP Deploy archive!" -ForegroundColor Red + throw $_ + } +} + +Function Publish-ZipFile { + Param( + [Parameter(Mandatory=$true)] + [string]$AppName, + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + [Parameter(Mandatory=$true)] + [System.IO.FileInfo]$ZipFilePath, + [Parameter(Mandatory=$false)] + [switch]$UseAPI + ) + + if ($UseAPI) { + Write-Host "â„šī¸ Using Kudu API for ZIP Deploy" -ForegroundColor Cyan + } + + $publishRetries = 3 + $publishSuccess = $False + + if ($UseAPI) { + $accessToken = Get-AccessToken -AsPlainText + $zipContents = Get-Item -Path $ZipFilePath + + $publishProfile = Get-AzWebAppPublishingProfile -Name $AppName -ResourceGroupName $ResourceGroupName + $zipUrl = ([System.uri]($publishProfile | Select-Xml -XPath "//publishProfile[@publishMethod='ZipDeploy']" | Select-Object -ExpandProperty Node).publishUrl).Scheme + } + + do { + try { + if (-not $UseAPI) { + Publish-AzWebApp ` + -Name $AppName ` + -ResourceGroupName $ResourceGroupName ` + -ArchivePath $ZipFilePath ` + -Restart ` + -Force ` + | Out-Null + } else { + Invoke-RestMethod ` + -Uri "https://${zipUrl}/api/zipdeploy" ` + -Method Post ` + -ContentType "multipart/form-data" ` + -Headers @{ "Authorization" = "Bearer $accessToken" } ` + -Form @{ file = $zipContents } ` + -StatusCodeVariable statusCode ` + | Out-Null + + if ($statusCode -ne 200) { + throw [System.Exception]::New("Error while uploading ZIP Deploy via Kudu API! ($statusCode)") + } + } + + $publishSuccess = $True + } catch { + if($publishRetries -gt 0) { + Write-Host " âš ī¸ Upload failed, retrying..." -ForegroundColor Yellow + $publishRetries-- + } else { + Write-Host " ❌ Unable to upload ZIP Deploy archive!" -ForegroundColor Red + throw $_ + } + } + } while ($publishSuccess -eq $False -and $publishRetries -ge 0) +} + +Function Get-RunningVersion { + Param( + [Parameter(Mandatory = $true)] + $ExistingApp + ) + + try { + $appUri = $ExistingApp.DefaultHostName + if ([string]::IsNullOrWhiteSpace($appUri)) { $appUri = $ExistingApp.HostNames[0] } + + $status = Invoke-RestMethod -Method Get -Uri "https://$appUri/api/status" -TimeoutSec 30 -ErrorAction Stop + return $status.version + } catch { + return $null + } +} + +Function Get-LatestReleaseVersion { + Param( + [Parameter(Mandatory = $true)] + [string]$GitHubUserName, + [Parameter(Mandatory = $true)] + [string]$GitHubRepoName + ) + + try { + $release = Invoke-RestMethod -Method GET -Uri "https://api.github.com/repos/$GitHubUserName/$GitHubRepoName/releases/latest" -ErrorAction Stop + return $release.tag_name + } catch { + return $null + } +} + +Function Get-UserConfirmation { + Param( + [Parameter(Mandatory = $true)] + [string]$PromptText + ) + + do { + $confirmation = Read-Host "$PromptText (Y/N)" + switch ($confirmation.Trim().ToUpper()) { + { $_ -in @("Y", "YES") } { Write-Host; return $true } + { $_ -in @("N", "NO") } { Write-Host; return $false } + default { Write-Host "Invalid input. Please enter Y or N." -ForegroundColor Red } + } + } while ($true) +} + +Function Get-AppSettingValue { + Param( + [Parameter(Mandatory = $true)] + [AllowNull()] + $AppSettings, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $setting = $AppSettings | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + return $setting ? $setting.Value : $null +} + +Function Update-IpamInfrastructure { + <# + Auto-detected infrastructure update that brings an existing Azure IPAM deployment in + line with current deployments by adding any missing infrastructure. Today this adds a + 'staging' slot; additional infrastructure updates can be added here over time. + + The production site is never modified. Resources are created from an additive Bicep + template (update/main.bicep) that mirrors production (runtime stack, vNet) and enforces + only the PRESENCE of baseline app settings (existing values are always preserved). + + An infrastructure-update failure is non-fatal: it warns and returns so the normal + production update can still proceed. + #> + Param( + [Parameter(Mandatory = $true)] + $ExistingApp, + [Parameter(Mandatory = $true)] + [string]$AppName, + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + [Parameter(Mandatory = $true)] + [string]$AppType, + [Parameter(Mandatory = $true)] + [bool]$IsFunction, + [Parameter(Mandatory = $true)] + [bool]$IsContainer, + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $SLOT_NAME = "staging" + $UNSUPPORTED_TIERS = @('Free', 'Shared', 'Basic', 'Dynamic') + + try { + # 1. Skip if a staging slot already exists (idempotent / re-runnable) + $existingSlot = Get-AzWebAppSlot ` + -ResourceGroupName $ResourceGroupName ` + -Name $AppName ` + -Slot $SLOT_NAME ` + -ErrorAction SilentlyContinue + + if ($existingSlot) { + Write-Host "â„šī¸ Infrastructure is already up to date, no updates required" -ForegroundColor Cyan + return + } + + Write-Section -Title "Azure IPAM Infrastructure Update" + Write-Host "This deployment is missing infrastructure that newer Azure IPAM deployments" -ForegroundColor Gray + Write-Host "include. The following updates will be applied additively to make future updates" -ForegroundColor Gray + Write-Host "and version changes safer. The production site will not be modified." -ForegroundColor Gray + Write-Host + + # 2. Verify the App Service Plan tier supports deployment slots + Write-Host "🔍 Verifying App Service Plan supports slots..." -ForegroundColor Cyan -NoNewline + $planResource = Get-AzResource -ResourceId $ExistingApp.ServerFarmId -ExpandProperties -ErrorAction SilentlyContinue + + if (-not $planResource) { + Write-Host " âš ī¸ Unable to resolve" -ForegroundColor Yellow + Write-Host " Skipping infrastructure update." -ForegroundColor Yellow + return + } + + $planTier = $planResource.Sku.tier + if ([string]::IsNullOrWhiteSpace($planTier)) { $planTier = $planResource.Sku.Tier } + + if ($planTier -in $UNSUPPORTED_TIERS) { + Write-Host " ❌ Unsupported ($planTier)" -ForegroundColor Red + Write-Host " The '$planTier' tier does not support deployment slots (requires Standard or higher)." -ForegroundColor Yellow + Write-Host " Skipping infrastructure update." -ForegroundColor Yellow + return + } + Write-Host " ✅ Supported ($planTier)" -ForegroundColor Green + + # 3. Verify a user-assigned managed identity is present (slot reuses it for KeyVault/Cosmos) + Write-Host "🔍 Verifying managed identity..." -ForegroundColor Cyan -NoNewline + $userAssignedIds = $ExistingApp.Identity.UserAssignedIdentities + + if (-not $userAssignedIds -or $userAssignedIds.Count -eq 0) { + Write-Host " ❌ Missing" -ForegroundColor Red + Write-Host " No user-assigned managed identity found; skipping infrastructure update." -ForegroundColor Yellow + return + } + + $managedIdentityId = @($userAssignedIds.Keys)[0] + $miResource = Get-AzResource -ResourceId $managedIdentityId -ExpandProperties -ErrorAction SilentlyContinue + $managedIdentityClientId = $miResource.Properties.clientId + + if ([string]::IsNullOrWhiteSpace($managedIdentityClientId)) { + Write-Host " ❌ Unresolved" -ForegroundColor Red + Write-Host " Could not resolve the managed identity client ID; skipping infrastructure update." -ForegroundColor Yellow + return + } + Write-Host " ✅ Verified" -ForegroundColor Green + + # 4. Verify the baseline app settings are present (non-standard deployments are skipped) + Write-Host "🔍 Verifying baseline configuration..." -ForegroundColor Cyan -NoNewline + $appSettings = $ExistingApp.SiteConfig.AppSettings + + $cosmosDbUri = Get-AppSettingValue -AppSettings $appSettings -Name 'COSMOS_URL' + $databaseName = Get-AppSettingValue -AppSettings $appSettings -Name 'DATABASE_NAME' + $containerName = Get-AppSettingValue -AppSettings $appSettings -Name 'CONTAINER_NAME' + $keyVaultUri = Get-AppSettingValue -AppSettings $appSettings -Name 'KEYVAULT_URL' + $azureEnv = Get-AppSettingValue -AppSettings $appSettings -Name 'AZURE_ENV' + $runFromPkg = Get-AppSettingValue -AppSettings $appSettings -Name 'WEBSITE_RUN_FROM_PACKAGE' + + $missingSettings = @() + if ([string]::IsNullOrWhiteSpace($cosmosDbUri)) { $missingSettings += 'COSMOS_URL' } + if ([string]::IsNullOrWhiteSpace($keyVaultUri)) { $missingSettings += 'KEYVAULT_URL' } + if ([string]::IsNullOrWhiteSpace($databaseName)) { $missingSettings += 'DATABASE_NAME' } + if ([string]::IsNullOrWhiteSpace($containerName)) { $missingSettings += 'CONTAINER_NAME' } + + if ($missingSettings.Count -gt 0) { + Write-Host " ❌ Incomplete" -ForegroundColor Red + Write-Host " Missing baseline app settings: $($missingSettings -join ', ')." -ForegroundColor Yellow + Write-Host " This does not look like a standard Azure IPAM deployment; skipping infrastructure update." -ForegroundColor Yellow + return + } + Write-Host " ✅ Verified" -ForegroundColor Green + + # 5. Derive remaining deployment characteristics + $linuxFxVersion = $ExistingApp.SiteConfig.LinuxFxVersion + $runFromPackage = ($runFromPkg -eq '1') + $azureCloud = [string]::IsNullOrWhiteSpace($azureEnv) ? 'AZURE_PUBLIC' : $azureEnv + + $privateAcr = $false + $privateAcrUri = '' + + if ($IsContainer) { + $appAcr = $linuxFxVersion.Split('|')[1].Split('/')[0] + if ($appAcr -notin $IPAM_PUBLIC_ACR) { + $privateAcr = $true + $privateAcrUri = $appAcr + } + } + + # Mirror production's ACR authentication method (managed identity vs admin/anonymous) + # rather than inferring it from the registry name. + $acrUseManagedIdentity = [bool]$ExistingApp.SiteConfig.AcrUseManagedIdentityCreds + + $prodSubnetId = $ExistingApp.VirtualNetworkSubnetId + $hasVnet = -not [string]::IsNullOrWhiteSpace($prodSubnetId) + + # 6. Assemble the resourceDetails object expected by update/main.bicep + $resourceDetails = @{ + appServiceName = $AppName + appServiceRG = $ResourceGroupName + appServicePlanId = $ExistingApp.ServerFarmId + managedIdentityId = $managedIdentityId + managedIdentityClientId = $managedIdentityClientId + cosmosDbUri = $cosmosDbUri + databaseName = $databaseName + containerName = $containerName + keyVaultUri = $keyVaultUri + linuxFxVersion = $linuxFxVersion + isFunction = $IsFunction + deployAsContainer = $IsContainer + privateAcr = $privateAcr + privateAcrUri = $privateAcrUri + acrUseManagedIdentity = $acrUseManagedIdentity + runFromPackage = $runFromPackage + } + + # 7. Summary + confirmation gate + $stackParts = $linuxFxVersion.Split('|') + $runtimeDisplay = switch ($stackParts[0]) { + 'PYTHON' { "Python $($stackParts[1])" } + 'DOCKER' { "Container ($($stackParts[1]))" } + default { $linuxFxVersion } + } + + Write-Host + Write-Host "Detected environment:" -ForegroundColor Yellow + $detected = [ordered]@{ + 'Application' = $AppName + 'Resource Group' = $ResourceGroupName + 'Deployment Type' = $AppType + 'Runtime Stack' = $runtimeDisplay + 'Private ACR' = $privateAcr ? "Yes ($privateAcrUri)" : 'No' + 'vNet Integration' = $hasVnet ? 'Yes' : 'No' + } + foreach ($entry in $detected.GetEnumerator()) { + Write-Host (" {0,-17}: " -f $entry.Key) -ForegroundColor Cyan -NoNewline + Write-Host $entry.Value -ForegroundColor White + } + + Write-Host + Write-Host "Will be created:" -ForegroundColor Yellow + $toCreate = [ordered]@{ + 'Deployment Slot' = "$SLOT_NAME (disabled)" + } + if ($hasVnet) { + $toCreate['vNet Integration'] = 'Yes (mirrored from production)' + } + foreach ($entry in $toCreate.GetEnumerator()) { + Write-Host (" {0,-17}: " -f $entry.Key) -ForegroundColor Cyan -NoNewline + Write-Host $entry.Value -ForegroundColor White + } + Write-Host + + if (-not $Force) { + $proceed = Get-UserConfirmation -PromptText "Apply the infrastructure update now?" + if (-not $proceed) { + Write-Host "â„šī¸ Infrastructure update skipped by user" -ForegroundColor Cyan + return + } + } + + # 8. Deploy the additive slot template (with graceful vNet fallback) + $bicepPath = Join-Path -Path $ROOT_DIR -ChildPath "update" -AdditionalChildPath "main.bicep" + + if (-not (Test-Path -Path $bicepPath)) { + Write-Host "âš ī¸ Infrastructure template not found at '$bicepPath'; skipping infrastructure update" -ForegroundColor Yellow + return + } + + $deploymentParameters = @{ + location = $ExistingApp.Location + azureCloud = $azureCloud + replicateVnet = $true + resourceDetails = $resourceDetails + } + + $deployment = $null + $vnetManual = $false + + Write-Host "🚀 Applying infrastructure update (this may take a few minutes)..." -ForegroundColor Cyan + + try { + $deployment = New-AzSubscriptionDeployment ` + -Name "ipamSlotMigrate-$(Get-Date -Format `"yyyyMMddhhmmsstt`")" ` + -Location $ExistingApp.Location ` + -TemplateFile $bicepPath ` + -TemplateParameterObject $deploymentParameters ` + -ErrorAction Stop + } + catch { + $deployError = $_ + + # Only fall back to a bare slot when the failure is actually vNet/subnet related + # (e.g. missing subnet join permission or delegation). Any other failure — such as + # insufficient subscription-scope deployment rights — is a genuine failure and is + # surfaced as-is rather than masked behind a misleading vNet retry. + $isVnetFailure = $hasVnet -and ($deployError.ToString() -match 'subnet|virtualnetwork|delegat') + + if ($isVnetFailure) { + Write-Host " â„šī¸ vNet integration could not be applied; retrying without it..." -ForegroundColor Cyan + $deployError | Out-File -FilePath $errorLog -Append + + $deploymentParameters.replicateVnet = $false + $vnetManual = $true + + $deployment = New-AzSubscriptionDeployment ` + -Name "ipamSlotMigrate-$(Get-Date -Format `"yyyyMMddhhmmsstt`")" ` + -Location $ExistingApp.Location ` + -TemplateFile $bicepPath ` + -TemplateParameterObject $deploymentParameters ` + -ErrorAction Stop + } + else { + throw $deployError + } + } + + Write-Section -Title "Infrastructure Update Complete" + Write-Host "✅ Deployment slot '$SLOT_NAME' created (disabled)" -ForegroundColor Green + + # vNet replication outcome / manual remediation guidance + if ($hasVnet) { + $vnetReplicated = -not $vnetManual -and [bool]$deployment.Outputs.vnetReplicated.Value + + if ($vnetReplicated) { + Write-Host "✅ vNet integration replicated to the staging slot" -ForegroundColor Green + } + else { + # Break the subnet resource ID into friendly parts for display; the full ID goes to the log + $subnetParts = [ordered]@{} + if ($prodSubnetId -match '/subscriptions/(?[^/]+)/resourceGroups/(?[^/]+)/providers/Microsoft\.Network/virtualNetworks/(?[^/]+)/subnets/(?[^/]+)') { + $subnetParts['Subscription'] = $Matches['sub'] + $subnetParts['Resource Group'] = $Matches['rg'] + $subnetParts['Virtual Network'] = $Matches['vnet'] + $subnetParts['Subnet'] = $Matches['subnet'] + } + + Write-Host + Write-Host "âš ī¸ vNet integration was not applied to the slot automatically." -ForegroundColor Yellow + Write-Host " To make the slot swap-ready, manually add regional vNet integration" -ForegroundColor Yellow + Write-Host " to the '$SLOT_NAME' slot using the subnet below:" -ForegroundColor Yellow + Write-Host + + if ($subnetParts.Count -gt 0) { + foreach ($part in $subnetParts.GetEnumerator()) { + Write-Host (" {0,-16}: " -f $part.Key) -ForegroundColor Cyan -NoNewline + Write-Host $part.Value -ForegroundColor White + } + } + else { + Write-Host " $prodSubnetId" -ForegroundColor Cyan + } + + Write-Host + Write-Host " Guidance: " -ForegroundColor Yellow -NoNewline + Write-Host "https://azure.github.io/ipam/#/update/README?id=vnet-integration-on-the-staging-slot" -ForegroundColor Cyan + Write-Host " The full subnet resource ID has been written to the log." -ForegroundColor Gray + + # Verbose detail for the log + "[InfrastructureUpdate] vNet integration was not applied to the '$SLOT_NAME' slot automatically." | Out-File -FilePath $errorLog -Append + "[InfrastructureUpdate] To make the slot swap-ready, manually add regional vNet integration using subnet: $prodSubnetId" | Out-File -FilePath $errorLog -Append + } + } + } + catch { + # Infrastructure update is best-effort: never block the core production update + $_ | Out-File -FilePath $errorLog -Append + + Write-Section -Title "Infrastructure Update Failed" + Write-Host "âš ī¸ The infrastructure update could not be completed." -ForegroundColor Yellow + Write-Host " The application update will continue — only this one-time step was skipped." -ForegroundColor Yellow + Write-Host " This is most often caused by insufficient deployment permissions at the" -ForegroundColor Yellow + Write-Host " subscription scope. Review the prerequisites, or re-run with -SkipInfraUpdate" -ForegroundColor Yellow + Write-Host " to bypass this step entirely." -ForegroundColor Yellow + Write-Host + Write-Host (" {0,-13}: " -f 'Prerequisites') -ForegroundColor Yellow -NoNewline + Write-Host "https://azure.github.io/ipam/#/update/README?id=prerequisites" -ForegroundColor Cyan + Write-Host (" {0,-13}: " -f 'Error Log') -ForegroundColor Yellow -NoNewline + Write-Host $errorLog -ForegroundColor Gray + } +} + +Start-Transcript -Path $updateLog | Out-Null + +try { + Write-Section -Title "Verifying Azure IPAM Application" + + $appType = "" + $isFunction = $false + $privateAcr = $false + $acrName = "" + + Write-Host "🔍 Verifying application exists..." -ForegroundColor Cyan -NoNewline + $existingApp = Get-AzWebApp -ResourceGroupName $ResourceGroupName -Name $AppName -ErrorAction SilentlyContinue + + if($null -eq $existingApp) { + Write-Host " ❌ Not found" -ForegroundColor Red + Write-Host " ❌ Application not found in current subscription!" -ForegroundColor Red + throw "Application does not exist!" + } else { + $appKind = $existingApp.Kind + $appType = $($appKind.Split(",") -contains 'functionapp') ? 'Function' : 'App' + $isFunction = $appType -eq 'Function' ? $true : $false + } + + $isContainer = $appKind.Split(",") -contains 'container' + + if ($isContainer) { + $appType += "Container" + } + + Write-Host " ✅ Found" -ForegroundColor Green + Write-Host "🔍 Detected deployment type: " -ForegroundColor Cyan -NoNewline + Write-Host $appType -ForegroundColor White + + if($isContainer -and $existingApp.SiteConfig.LinuxFxVersion.StartsWith("COMPOSE|")) { + Write-Section -Title "Manual Migration Required" + Write-Host "This deployment uses the legacy Docker Compose configuration, which is no longer" -ForegroundColor Yellow + Write-Host "supported and cannot be updated automatically." -ForegroundColor Yellow + Write-Host + Write-Host "To migrate to the current single-container deployment, follow the migration guide:" -ForegroundColor Yellow + Write-Host " https://azure.github.io/ipam/#/migration/README" -ForegroundColor Cyan + Write-Host + exit + } + + if ($null -eq $existingApp.SiteConfig.HealthCheckPath) { + Write-Host "âš ī¸ Health check missing; adding application health check..." -ForegroundColor Yellow + Set-HealthCheck -ResourceGroupName $ResourceGroupName -AppName $AppName + Write-Host " ✅ Health check configured" -ForegroundColor Green + } + + # Auto-detected infrastructure updates (additive; production site is never modified). + # Today this provisions the 'staging' slot; future infrastructure updates can be + # added here under the same -SkipInfraUpdate switch. + if (-not $SkipInfraUpdate) { + Update-IpamInfrastructure ` + -ExistingApp $existingApp ` + -AppName $AppName ` + -ResourceGroupName $ResourceGroupName ` + -AppType $appType ` + -IsFunction $isFunction ` + -IsContainer $isContainer ` + -Force:$Force + } + + if ($isContainer) { + Write-Section -Title "Updating Container Deployment" + + $appAcr = $existingApp.SiteConfig.LinuxFxVersion.Split('|')[1].Split('/')[0] + $privateAcr = $appAcr -in $IPAM_PUBLIC_ACR ? $false : $true + + if (-not $privateAcr) { + $currentPublicAcr = $IPAM_PUBLIC_ACR[0] + + if ($appAcr -ne $currentPublicAcr) { + Write-Host "🔍 Legacy public ACR detected (" -ForegroundColor Cyan -NoNewline + Write-Host "$appAcr" -ForegroundColor White -NoNewline + Write-Host "), migrating image reference to " -ForegroundColor Cyan -NoNewline + Write-Host "$currentPublicAcr" -ForegroundColor White -NoNewline + Write-Host "..." -ForegroundColor Cyan + + $existingApp.SiteConfig.LinuxFxVersion = $existingApp.SiteConfig.LinuxFxVersion.Replace($appAcr, $currentPublicAcr) + $existingApp | Set-AzWebApp | Out-Null + + Write-Host " ✅ Image reference migrated" -ForegroundColor Green + + Start-Sleep -Seconds 10 + } + + Write-Host "â„šī¸ Deployment is using the Azure IPAM public ACR; restarting to pull the latest image" -ForegroundColor Cyan + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName + + Write-Section -Title "Azure IPAM Update Complete" + Write-Host "✅ Azure IPAM solution updated successfully" -ForegroundColor Green + Write-Host "â„šī¸ Please allow a few minutes for the container to restart and load the updated image" -ForegroundColor Cyan + Write-Host + exit + } + + if($privateAcr) { + $acrName = $appAcr.Split('.')[0] + + Write-Host "🔍 Private ACR detected: " -ForegroundColor Cyan -NoNewline + Write-Host "$acrName" -ForegroundColor White + + Write-Host "🔍 Looking for the ACR in Resource Group '$ResourceGroupName'..." -ForegroundColor Cyan -NoNewline + $acrDetails = Get-AzContainerRegistry ` + -Name $acrName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorVariable acrErr ` + -ErrorAction SilentlyContinue + + if ($acrErr) { + $acrErr | Out-File -FilePath $errorLog -Append + + Write-Host " âš ī¸ Not found" -ForegroundColor Yellow + + $appNoun = $isFunction ? 'Function App' : 'App Service' + + Write-Section -Title "Manual Image Update Required" + Write-Host "The container registry is not located in the $appNoun's resource group, so this" -ForegroundColor Yellow + Write-Host "image cannot be updated automatically." -ForegroundColor Yellow + Write-Host + Write-Host " Container Registry: " -ForegroundColor Cyan -NoNewline + Write-Host $appAcr -ForegroundColor White + Write-Host + + Write-Host "To perform this update manually, build and push a new image to the registry," -ForegroundColor Yellow + Write-Host "then restart the $appNoun to load it. For build instructions, see:" -ForegroundColor Yellow + Write-Host " https://azure.github.io/ipam/#/update/README?id=container-build-failures" -ForegroundColor Cyan + Write-Host + exit + } + + $acrName = $acrDetails.Name + Write-Host " ✅ Found" -ForegroundColor Green + + # Verify Minimum Azure CLI Version + Write-Host "🔍 Verifying minimum Azure CLI version..." -ForegroundColor Cyan -NoNewline + $azureCliVer = [System.Version](az version | ConvertFrom-Json).'azure-cli' + + if($azureCliVer -lt $MIN_AZ_CLI_VER) { + Write-Host " ❌ Outdated" -ForegroundColor Red + Write-Host " ❌ Azure CLI must be version $MIN_AZ_CLI_VER or greater!" -ForegroundColor Red + exit + } + Write-Host " ✅ OK ($azureCliVer)" -ForegroundColor Green + + # Verify Azure PowerShell and Azure CLI Contexts Match + Write-Host "🔍 Verifying Azure PowerShell and Azure CLI contexts match..." -ForegroundColor Cyan -NoNewline + $azureCliContext = $(az account show | ConvertFrom-Json) 2>$null + + if(-not $azureCliContext) { + Write-Host " ❌ Not logged in" -ForegroundColor Red + Write-Host " ❌ Azure CLI not logged in or no subscription has been selected!" -ForegroundColor Red + exit + } + + $azureCliSub = $azureCliContext.id + $azurePowerShellSub = (Get-AzContext).Subscription.Id + + if($azurePowerShellSub -ne $azureCliSub) { + Write-Host " ❌ Mismatch" -ForegroundColor Red + Write-Host " ❌ Azure PowerShell and Azure CLI must be set to the same context!" -ForegroundColor Red + exit + } + Write-Host " ✅ Match" -ForegroundColor Green + } + } + + $skipZipDeploy = $false + + if (-not $isContainer) { + Write-Section -Title "Updating Native (ZIP Deploy) Application" + + # Compare the running version with the latest release (GitHub download path only) + if (-not $ZipFilePath -and -not $Force) { + Write-Host "🔍 Comparing deployed version with the latest release..." -ForegroundColor Cyan -NoNewline + + $runningVersion = Get-RunningVersion -ExistingApp $existingApp + $latestVersion = Get-LatestReleaseVersion -GitHubUserName $GitHubUserName -GitHubRepoName $GitHubRepoName + + $runningClean = $runningVersion -replace '^v', '' + $latestClean = $latestVersion -replace '^v', '' + + if ($runningClean -and $latestClean -and ($runningClean -eq $latestClean)) { + Write-Host " ✅ Up to date (v$runningClean)" -ForegroundColor Green + $skipZipDeploy = $true + } + elseif ($runningClean -and $latestClean) { + Write-Host " âš ī¸ Update available (v$runningClean -> v$latestClean)" -ForegroundColor Yellow + } + else { + Write-Host " â„šī¸ Unable to determine; proceeding with deploy" -ForegroundColor Cyan + } + } + + if (-not $skipZipDeploy) { + Write-Host "🔍 Verifying application Python version..." -ForegroundColor Cyan -NoNewline + + $engineFolder = Join-Path -Path $ROOT_DIR -ChildPath 'engine' + $engineVersionFile = Join-Path -Path $engineFolder -ChildPath "app" -AdditionalChildPath 'version.json' + $enginePythonVersion = $(Get-Content -Path $engineVersionFile | ConvertFrom-Json).python + + $appPythonVersion = $existingApp.SiteConfig.LinuxFxVersion.Split('|')[1] + + if($enginePythonVersion -ne $appPythonVersion) { + Write-Host " âš ī¸ Changed (v$appPythonVersion -> v$enginePythonVersion)" -ForegroundColor Yellow + Write-Host "🔍 Updating application Python version..." -ForegroundColor Cyan -NoNewline + + $existingApp.SiteConfig.LinuxFxVersion = "PYTHON|$enginePythonVersion" + $existingApp | Set-AzWebApp | Out-Null + + Write-Host " ✅ Updated" -ForegroundColor Green + + Start-Sleep -Seconds 10 + } + else { + Write-Host " ✅ Up to date (v$appPythonVersion)" -ForegroundColor Green + } + } + } + + if ($isContainer) { + Write-Section -Title "Building Container Image" + + # Version used to tag the image (mirrors the CI convention: + latest) + $engineVersionFile = Join-Path -Path $ROOT_DIR -ChildPath 'engine' -AdditionalChildPath 'app', 'version.json' + $ipamVersion = $(Get-Content -Path $engineVersionFile | ConvertFrom-Json).app + + # Skip the image build when the running version already matches the repository (unless -Force). + # Both /api/status and version.json report bare versions (e.g. 3.6.0), so no normalization is needed. + if (-not $Force) { + Write-Host "🔍 Comparing the running version with the repository..." -ForegroundColor Cyan -NoNewline + + $runningVersion = Get-RunningVersion -ExistingApp $existingApp + + if ($runningVersion -and ($runningVersion -eq $ipamVersion)) { + Write-Host " ✅ Up to date (v$ipamVersion)" -ForegroundColor Green + + Write-Section -Title "Azure IPAM Update Complete" + Write-Host "✅ Azure IPAM is already running the latest version (v$ipamVersion)" -ForegroundColor Green + Write-Host + exit + } + elseif ($runningVersion) { + Write-Host " âš ī¸ Update available (v$runningVersion -> v$ipamVersion)" -ForegroundColor Yellow + } + else { + Write-Host " â„šī¸ Unable to determine; proceeding with build" -ForegroundColor Cyan + } + } + + if (-not $isFunction) { + Write-Host "🔍 Detecting container distro..." -ForegroundColor Cyan -NoNewline + + $appUri = $existingApp.HostNames[0] + $statusUri = "https://${appUri}/api/status" + + try { + $status = Invoke-RestMethod -Method Get -Uri $statusUri -ErrorVariable statusErr -ErrorAction SilentlyContinue + } catch { + Write-Host " ❌ Failed" -ForegroundColor Red + Write-Host " ❌ Unable to detect container distro!" -ForegroundColor Red + throw $_ + } + + $containerType = $status.container.image_id + Write-Host " ✅ $containerType" -ForegroundColor Green + } + + $containerMap = @{ + debian = @{ + Extension = 'deb' + Port = 80 + Images = @{ + Build = 'node:22-slim' + Serve = 'python:3.11-slim' + } + } + rhel = @{ + Extension = 'rhel' + Port = 8080 + Images = @{ + Build = 'registry.access.redhat.com/ubi9/nodejs-22' + Serve = 'registry.access.redhat.com/ubi9/python-311' + } + } + } + + if($containerType) { + $dockerFile = 'Dockerfile.' + $containerMap[$containerType].Extension + $dockerFilePath = Join-Path -Path $ROOT_DIR -ChildPath $dockerFile + } + + $dockerFileFunc = Join-Path -Path $ROOT_DIR -ChildPath 'Dockerfile.func' + + if($isFunction) { + Write-Host "🚀 Building and pushing Function container image..." -ForegroundColor Cyan + + $funcBuildOutput = $( + az acr build -r $acrName ` + -t ipamfunc:$ipamVersion ` + -t ipamfunc:latest ` + -f $dockerFileFunc $ROOT_DIR ` + --build-arg IPAM_VERSION=$ipamVersion ` + --no-logs + ) *>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Host " ❌ Container build failed, fetching error logs..." -ForegroundColor Red + + $buildId = [regex]::Matches($funcBuildOutput, "(?<=Queued a build with ID: )[\w]*").Value.Trim() + + $buildLogs = Get-BuildLogs ` + -SubscriptionId (Get-AzContext).Subscription.Id ` + -ResourceGroupName $ResourceGroupName ` + -RegistryName $acrName ` + -BuildId $buildId + + $buildLogs | Out-File -FilePath $errorLog -Append + + $script:containerBuildError = $true + } else { + Write-Host " ✅ Function container image built and pushed" -ForegroundColor Green + } + + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName -Function + } else { + Write-Host "🚀 Building and pushing App container image (" -ForegroundColor Cyan -NoNewline + Write-Host "$containerType" -ForegroundColor White -NoNewline + Write-Host ")..." -ForegroundColor Cyan + + $appBuildOutput = $( + az acr build -r $acrName ` + -t ipam:$ipamVersion ` + -t ipam:latest ` + -f $dockerFilePath $ROOT_DIR ` + --build-arg PORT=$($containerMap[$ContainerType].Port) ` + --build-arg BUILD_IMAGE=$($containerMap[$containerType].Images.Build) ` + --build-arg SERVE_IMAGE=$($containerMap[$containerType].Images.Serve) ` + --build-arg IPAM_VERSION=$ipamVersion ` + --no-logs + ) *>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Host " ❌ Container build failed, fetching error logs..." -ForegroundColor Red + + $buildId = [regex]::Matches($appBuildOutput, "(?<=Queued a build with ID: )[\w]*").Value.Trim() + + $buildLogs = Get-BuildLogs ` + -SubscriptionId (Get-AzContext).Subscription.Id ` + -ResourceGroupName $ResourceGroupName ` + -RegistryName $acrName ` + -BuildId $buildId + + $buildLogs | Out-File -FilePath $errorLog -Append + + $script:containerBuildError = $true + } else { + Write-Host " ✅ App container image built and pushed" -ForegroundColor Green + } + + Restart-IpamApp -AppName $AppName -ResourceGroupName $ResourceGroupName + } + + if(-not $containerBuildError) { + Write-Section -Title "Azure IPAM Update Complete" + Write-Host "✅ Azure IPAM solution updated successfully" -ForegroundColor Green + Write-Host "â„šī¸ Please allow a few minutes for the container to restart and load the updated image" -ForegroundColor Cyan + Write-Host + } else { + Write-Section -Title "Azure IPAM Update Completed With Errors" + Write-Host "âš ī¸ Azure IPAM solution deployed with errors, see logs for details!" -ForegroundColor Yellow + Write-Host " Run Log: $updateLog" -ForegroundColor Yellow + Write-Host " Error Log: $errorLog" -ForegroundColor Yellow + Write-Host + } + } elseif ($skipZipDeploy) { + Write-Section -Title "Azure IPAM Update Complete" + Write-Host "✅ Azure IPAM is already running the latest version (v$runningClean)" -ForegroundColor Green + Write-Host + } else { + if (-Not $ZipFilePath) { + if (-Not $AssetFolder) { + try { + # Create a temporary folder path + $TempFolder = Join-Path -Path TEMP:\ -ChildPath $(New-Guid) + + # Create directory if not exists + $script:TempFolderObj = New-Item -ItemType Directory -Path $TempFolder -Force + + $script:AssetFolder = $TempFolderObj + } catch { + Write-Host " ❌ Unable to create temp directory to store ZIP archive!" -ForegroundColor Red + throw $_ + } + } else { + $script:AssetFolder = Get-Item -Path $AssetFolder + } + + Get-ZipFile -GitHubUserName $GitHubUserName -GitHubRepoName $GitHubRepoName -ZipFileName $ZipFileName -AssetFolder $AssetFolder + + $script:ZipFilePath = Join-Path -Path $AssetFolder.FullName -ChildPath $ZipFileName + } else { + $script:ZipFilePath = Get-Item -Path $ZipFilePath + } + + Write-Host "🚀 Uploading ZIP Deploy archive..." -ForegroundColor Cyan + + try { + Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName -ZipFilePath $ZipFilePath + } catch { + Write-Host " âš ī¸ Standard ZIP Deploy failed, retrying with Kudu API..." -ForegroundColor Yellow + Publish-ZipFile -AppName $AppName -ResourceGroupName $ResourceGroupName -ZipFilePath $ZipFilePath -UseAPI + } + + if ($TempFolderObj) { + Write-Host "🔍 Cleaning up temporary directory..." -ForegroundColor Cyan -NoNewline + Remove-Item -LiteralPath $TempFolderObj.FullName -Force -Recurse -ErrorAction SilentlyContinue + $script:TempFolderObj = $null + Write-Host " ✅ Done" -ForegroundColor Green + } + + Write-Section -Title "Azure IPAM Update Complete" + Write-Host "✅ Azure IPAM solution updated successfully" -ForegroundColor Green + Write-Host "â„šī¸ Please allow ~5 minutes for the ZIP Deploy process to complete" -ForegroundColor Cyan + Write-Host + } +} +catch { + $_ | Out-File -FilePath $errorLog -Append + Write-Host + Write-Host "❌ Unable to update Azure IPAM application, see log for detailed information!" -ForegroundColor Red + Write-Host " Error Log: $errorLog" -ForegroundColor Red + + if ($env:CI) { + Write-Host $_.ToString() + } + + exit 1 +} +finally { + if ($TempFolderObj) { + Remove-Item -LiteralPath $TempFolderObj.FullName -Force -Recurse -ErrorAction SilentlyContinue + } + + Write-Host + Stop-Transcript | Out-Null +}