diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 922ef82..cf80c12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: - main - dev - demo + pull_request: + branches: + - dev schedule: - cron: '0 9,21 * * *' # Runs at 9:00 AM and 9:00 PM GMT workflow_dispatch: # Allow manual triggering @@ -113,7 +116,8 @@ jobs: --parameters solutionName=${{env.SOLUTION_PREFIX}} \ --parameters location=${{ env.AZURE_LOCATION }} \ --parameters aiDeploymentLocation=${{ env.AZURE_LOCATION }} \ - --parameters azureAiServiceLocation=${{ env.AZURE_LOCATION }} + --parameters azureAiServiceLocation=${{ env.AZURE_LOCATION }} \ + --parameters createdBy="pipeline" \ - name: Extract AI Services and Key Vault Names diff --git a/azure_custom.yaml b/azure_custom.yaml new file mode 100644 index 0000000..8ac9309 --- /dev/null +++ b/azure_custom.yaml @@ -0,0 +1,108 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +# This file contains a developer‑focused Azure Developer CLI configuration. It +# extends the default template by defining three services (backend API, +# processor and frontend) and instructs azd to build container images from +# your local source code. All three services are packaged as container apps +# using the Dockerfiles located in their respective project directories. +# After deployment a post‑deploy hook prints the endpoints of the deployed +# container apps. + +name: container-migration-solution-accelerator +metadata: + template: container-migration-solution-accelerator@1.0 + +requiredVersions: + # Require a recent version of azd that supports the packaging + # functionality used here. Versions less than 1.17.1 had a bug in + # remoteBuild. + azd: ">=1.18.2" + +infra: + parameters: + backendImageName: ${SERVICE_BACKEND_IMAGE_NAME} + processorImageName: ${SERVICE_PROCESSOR_IMAGE_NAME} + frontendImageName: ${SERVICE_FRONTEND_IMAGE_NAME} + +services: + # Backend API service. This is a Python FastAPI application defined in + # src/backend-api. The azd packaging stage builds a Docker image using + # the Dockerfile in that directory. The image name 'backend-api' is + # combined with the automatically created Azure Container Registry login + # server to form the final image reference. + backend: + project: ./src/backend-api + language: py + host: containerapp + docker: + image: backend-api + remoteBuild: true + + # Processor service. This service reads messages from storage queues and + # orchestrates long‑running migrations. It is also packaged as a + # container and deployed to a container app environment. The Dockerfile + # in the src/processor directory defines how the image is built. + processor: + project: ./src/processor + language: py + host: containerapp + docker: + image: processor + remoteBuild: true + + # Frontend service. The frontend consists of a React single‑page + # application and a lightweight Python server that serves the compiled + # assets. azd packages the frontend by building a Docker image using + # the Dockerfile in the src/frontend directory and deploys it to a + # container app. + frontend: + project: ./src/frontend + language: py + host: containerapp + docker: + image: frontend + remoteBuild: true + +hooks: + # After deployment prints the names and endpoints of the deployed + # container apps. This reproduces the behaviour of the default + # configuration so that developers can easily discover their services. + postdeploy: + posix: + shell: sh + run: | + echo "-----" + echo "🧭 Frontend Container App Details:" + echo "✅ Name: $CONTAINER_FRONTEND_APP_NAME" + echo "🌐 Endpoint: https://$CONTAINER_FRONTEND_APP_FQDN" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_FRONTEND_APP_NAME" + echo "-----" + echo "🧭 Backend API Container App Details:" + echo "✅ Name: $CONTAINER_API_APP_NAME" + echo "🌐 Endpoint: https://$CONTAINER_API_APP_FQDN" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_API_APP_NAME" + echo "-----" + echo "🧭 Processor Container App Details:" + echo "✅ Name: $SERVICE_PROCESSOR_NAME" + echo "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$SERVICE_PROCESSOR_NAME" + echo "-----" + interactive: true + windows: + shell: pwsh + run: | + Write-Host "-----" + Write-Host "🧭 Frontend Container App Details:" + Write-Host "✅ Name: $env:CONTAINER_FRONTEND_APP_NAME" + Write-Host "🌐 Endpoint: https://$env:CONTAINER_FRONTEND_APP_FQDN" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_FRONTEND_APP_NAME" -ForegroundColor Cyan + Write-Host "-----" + Write-Host "🧭 Backend API Container App Details:" + Write-Host "✅ Name: $env:CONTAINER_API_APP_NAME" + Write-Host "🌐 Endpoint: https://$env:CONTAINER_API_APP_FQDN" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:CONTAINER_API_APP_NAME" -ForegroundColor Cyan + Write-Host "-----" + Write-Host "🧭 Processor Container App Details:" + Write-Host "✅ Name: $env:SERVICE_PROCESSOR_NAME" + Write-Host "🔗 Portal URL: https://portal.azure.com/#resource/subscriptions/$env:AZURE_SUBSCRIPTION_ID/resourceGroups/$env:AZURE_RESOURCE_GROUP/providers/Microsoft.App/containerApps/$env:SERVICE_PROCESSOR_NAME" -ForegroundColor Cyan + Write-Host "-----" + interactive: true diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 862f55e..a5fe2e7 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -315,6 +315,7 @@ After successful deployment: ```shell azd down ``` +> **Note:** If you deployed with `enableRedundancy=true` and Log Analytics workspace replication is enabled, you must first disable replication before running `azd down` else resource group delete will fail. Follow the steps in [Handling Log Analytics Workspace Deletion with Replication Enabled](./LogAnalyticsReplicationDisable.md), wait until replication returns `false`, then run `azd down`. ### Manual Cleanup (if needed) If deployment fails or you need to clean up manually: @@ -430,3 +431,10 @@ azd env get-values - 🐛 **Issues:** Check [Troubleshooting Guide](./TroubleShootingSteps.md) - 💬 **Support:** Review [Support Guidelines](../SUPPORT.md) - 🔧 **Development:** See [Contributing Guide](../CONTRIBUTING.md) + +### Deploy Your local changes +To Deploy your local changes rename the below files. + +Rename `azure.yaml` to `azure_custom2.yaml` and `azure_custom.yaml` to `azure.yaml`. +Go to `infra` directory +Rename `main.bicep` to `main_custom2.bicep` and `main_custom.bicep` to `main.bicep`. Continue with the [deploying steps](#deploying-with-azd). diff --git a/docs/LogAnalyticsReplicationDisable.md b/docs/LogAnalyticsReplicationDisable.md new file mode 100644 index 0000000..f4379a8 --- /dev/null +++ b/docs/LogAnalyticsReplicationDisable.md @@ -0,0 +1,28 @@ +# 🛠 Handling Log Analytics Workspace Deletion with Replication Enabled + +If redundancy (replication) is enabled for your Log Analytics workspace, you must disable it before deleting the workspace or resource group. Otherwise, deletion will fail. + +## ✅ Steps to Disable Replication Before Deletion +Run the following Azure CLI command. Note: This operation may take about 5 minutes to complete. + +```bash +az resource update --ids "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{logAnalyticsName}" --set properties.replication.enabled=false +``` + +Replace: +- `{subscriptionId}` → Your Azure subscription ID +- `{resourceGroupName}` → The name of your resource group +- `{logAnalyticsName}` → The name of your Log Analytics workspace + +Optional: Verify replication disabled (should output `false`): +```bash +az resource show --ids "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.OperationalInsights/workspaces/{logAnalyticsName}" --query properties.replication.enabled -o tsv +``` + +## ✅ After Disabling Replication +You can safely delete: +- The Log Analytics workspace (manual) +- The resource group (manual), or +- All provisioned resources via `azd down` + +Return to: [Deployment Guide](./DeploymentGuide.md) diff --git a/infra/main.bicep b/infra/main.bicep index 067485c..ee085d6 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -53,6 +53,7 @@ param azureAiServiceLocation string 'westus3' ]) @description('Required. Azure region for AI model deployment. Should match azureAiServiceLocation for optimal performance.') +#disable-next-line no-unused-params param aiDeploymentLocation string = azureAiServiceLocation @description('Optional. The host (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry.') @@ -99,9 +100,12 @@ param cosmosLocation string = 'eastus2' param existingLogAnalyticsWorkspaceId string = '' @description('Tag, Created by user name') -param createdBy string = contains(deployer(), 'userPrincipalName') - ? split(deployer().userPrincipalName, '@')[0] - : deployer().objectId +param createdBy string = deployer().objectId + +// Get the current deployer's information for local debugging permissions +var deployerInfo = deployer() +var deployingUserPrincipalId = deployerInfo.objectId +var deployingUserType = contains(deployerInfo, 'userPrincipalName') ? 'User' : 'ServicePrincipal' @description('Optional. Resource ID of an existing Foundry project') param existingFoundryProjectResourceId string = '' @@ -469,6 +473,17 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' } + // Add deployer permissions + { + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalId: deployingUserPrincipalId + principalType: deployingUserType + } + { + roleDefinitionIdOrName: 'Storage Queue Data Contributor' + principalId: deployingUserPrincipalId + principalType: deployingUserType + } ] // WAF aligned networking networkAcls: { @@ -655,6 +670,12 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { principalType: 'ServicePrincipal' roleDefinitionIdOrName: 'DocumentDB Account Contributor' } + // Add deployer for local debugging + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'DocumentDB Account Contributor' + } ] // Create custom data plane role definition and assignment dataPlaneRoleDefinitions: [ @@ -669,6 +690,8 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { ] assignments: [ { principalId: appIdentity.outputs.principalId } + // ADD THIS for local debugging support: + { principalId: deployingUserPrincipalId } ] } ] @@ -699,7 +722,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b name: take('module.ai-services-model-deployments.${existingAiFoundryAiServices.name}', 64) scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) params: { - name: existingAiFoundryAiServices.name + name: aiFoundryAiServicesResourceName // Fix: use variable instead of resource reference deployments: [ { name: aiModelDeploymentName @@ -715,6 +738,7 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b } ] roleAssignments: [ + // Service Principal permissions { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' @@ -723,12 +747,23 @@ module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.b { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' } { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' - roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' + } + // Deployer permissions + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services User' } ] } @@ -746,6 +781,7 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF accountName:aiFoundryAiServicesResourceName allowProjectManagement: true roleAssignments: [ + // Service Principal permissions { principalId: appIdentity.outputs.principalId principalType: 'ServicePrincipal' @@ -761,6 +797,17 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF principalType: 'ServicePrincipal' roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User } + // Deployer permissions for local debugging + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: deployingUserPrincipalId + principalType: deployingUserType + roleDefinitionIdOrName: 'Cognitive Services User' + } ] // Remove networking configuration to avoid AML workspace creation issues networking: enablePrivateNetworking? { @@ -791,7 +838,7 @@ module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiF } } -var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundry.outputs.aiServicesName +var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundryAiServicesResourceName module appConfiguration 'br/public:avm/res/app-configuration/configuration-store:0.9.1' = { name: take('avm.res.app-config.store.${solutionSuffix}', 64) params: { @@ -898,6 +945,8 @@ module appConfiguration 'br/public:avm/res/app-configuration/configuration-store sku: 'Standard' publicNetworkAccess: 'Enabled' } + // Add explicit dependency + dependsOn: useExistingAiFoundryAiProject ? [] : [aiFoundry] } module avmAppConfigUpdated 'br/public:avm/res/app-configuration/configuration-store:0.6.3' = if (enablePrivateNetworking) { @@ -971,7 +1020,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11. platformReservedDnsIP: '172.17.17.17' zoneRedundant: (enablePrivateNetworking) ? true : false // Enable zone redundancy if private networking is enabled infrastructureSubnetResourceId: (enablePrivateNetworking) - ? virtualNetwork.outputs.containersSubnetResourceId // Use the container app subnet + ? virtualNetwork!.outputs.containersSubnetResourceId // Use the container app subnet : null // Use the container app subnet } } @@ -1210,3 +1259,7 @@ output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId @description('The Azure resource group name.') output AZURE_RESOURCE_GROUP string = resourceGroup().name + +// Log deployer information for debugging +output deployerObjectId string = deployingUserPrincipalId +output deployerType string = deployingUserType diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep new file mode 100644 index 0000000..7baa89e --- /dev/null +++ b/infra/main_custom.bicep @@ -0,0 +1,1296 @@ +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(16) +@description('Required. A unique application/solution name for all resources in this deployment. This should be 3-16 characters long.') +param solutionName string + +@maxLength(5) +@description('Optional. A unique text/token for the solution. This is used to ensure resource names are unique for global resources. Defaults to a 5-character substring of the unique string generated from the subscription ID, resource group name, and solution name.') +param solutionUniqueText string = substring(uniqueString(subscription().id, resourceGroup().name, solutionName), 0, 5) + +@minLength(3) +@metadata({ azd: { type: 'location' } }) +@description('Required. Azure region for container apps, storage, and other services. Choose a region close to your users.') +param location string +var solutionLocation = empty(location) ? resourceGroup().location : location + +@allowed([ + 'australiaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'japaneast' + 'norwayeast' + 'southindia' + 'swedencentral' + 'uksouth' + 'westus' + 'westus3' +]) +@metadata({ + azd: { + type: 'location' + usageName: [ + 'OpenAI.GlobalStandard.o3, 500' + ] + } +}) +@description('Required. Azure region for AI services (OpenAI/AI Foundry). Must be a region that supports o3 model deployment.') +param azureAiServiceLocation string + + + + + +@secure() +@description('The full image name (including tag) for the backend API container, generated by azd.') +param backendImageName string = '' + +@secure() +@description('The full image name (including tag) for the processor container, generated by azd.') +param processorImageName string = '' + +@secure() +@description('The full image name (including tag) for the frontend container, generated by azd.') +param frontendImageName string = '' + +@minLength(1) +@allowed(['Standard', 'GlobalStandard']) +@description('Optional. Model deployment type. Defaults to GlobalStandard.') +param aiDeploymentType string = 'GlobalStandard' + +@minLength(1) +@description('Optional. Name of the AI model to deploy. Recommend using o3. Defaults to o3.') +param aiModelName string = 'o3' + +@minLength(1) +@description('Optional. Version of AI model. Review available version numbers per model before setting. Defaults to 2025-04-16.') +param aiModelVersion string = '2025-04-16' + +@description('Optional. AI model deployment token capacity. Lower this if initial provisioning fails due to capacity. Defaults to 50K tokens per minute to improve regional success rate.') +param aiModelCapacity int = 1 + +@description('Optional. The tags to apply to all deployed Azure resources.') +param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} + +@description('Optional. Enable redundancy for applicable resources. Defaults to false.') +param enableRedundancy bool = false + +@description('Optional. Enable/Disable usage telemetry for module.') +param enableTelemetry bool = true + +@description('Optional. Enable private networking for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enablePrivateNetworking bool = false + +@description('Optional. Enable monitoring applicable resources, aligned with the Well Architected Framework recommendations. This setting enables Application Insights and Log Analytics and configures all the resources applicable resources to send logs. Defaults to false.') +param enableMonitoring bool = false + +@description('Optional. Enable scalability for applicable resources, aligned with the Well Architected Framework recommendations. Defaults to false.') +param enableScalability bool = false + +@description('Optional. CosmosDB Location') +param cosmosLocation string = 'eastus2' + +@description('Optional. Existing Log Analytics Workspace Resource ID') +param existingLogAnalyticsWorkspaceId string = '' + +@description('Tag, Created by user name') +param createdBy string = contains(deployer(), 'userPrincipalName') + ? split(deployer().userPrincipalName, '@')[0] + : deployer().objectId + +// Get the current deployer's information +var deployerInfo = deployer() +var deployingUserPrincipalId = deployerInfo.objectId + +@description('Optional. Resource ID of an existing Foundry project') +param existingFoundryProjectResourceId string = '' + +@description('Optional. Admin username for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +//param vmAdminUsername string = take(newGuid(), 20) +param vmAdminUsername string? + +@description('Optional. Admin password for the Jumpbox Virtual Machine. Set to custom value if enablePrivateNetworking is true.') +@secure() +//param vmAdminPassword string = newGuid() +param vmAdminPassword string? + +@description('Optional. Size of the Jumpbox Virtual Machine when created. Set to custom value if enablePrivateNetworking is true.') +param vmSize string? + +// Extracts subscription, resource group, and workspace name from the resource ID when using an existing Log Analytics workspace +var useExistingLogAnalytics = !empty(existingLogAnalyticsWorkspaceId) +var existingLawSubscription = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[2] : '' +var existingLawResourceGroup = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[4] : '' +var existingLawName = useExistingLogAnalytics ? split(existingLogAnalyticsWorkspaceId, '/')[8] : '' + +resource existingLogAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' existing = if (useExistingLogAnalytics) { + name: existingLawName + scope: resourceGroup(existingLawSubscription, existingLawResourceGroup) +} + +var logAnalyticsWorkspaceResourceId = useExistingLogAnalytics + ? existingLogAnalyticsWorkspaceId + : logAnalyticsWorkspace!.outputs.resourceId + +var solutionSuffix = toLower(trim(replace( + replace( + replace(replace(replace(replace('${solutionName}${solutionUniqueText}', '-', ''), '_', ''), '.', ''), '/', ''), + ' ', + '' + ), + '*', + '' +))) + +var allTags = union( + { + 'azd-env-name': solutionName + TemplateName: 'Container Migration' + }, + tags +) + +resource resourceGroupTags 'Microsoft.Resources/tags@2021-04-01' = { + name: 'default' + properties: { + tags: { + ...resourceGroup().tags + ...tags + TemplateName: 'Container Migration' + Type: enablePrivateNetworking ? 'WAF' : 'Non-WAF' + CreatedBy: createdBy + } + } +} + +// Replica regions list based on article in [Azure regions list](https://learn.microsoft.com/azure/reliability/regions-list) and [Enhance resilience by replicating your Log Analytics workspace across regions](https://learn.microsoft.com/azure/azure-monitor/logs/workspace-replication#supported-regions) for supported regions for Log Analytics Workspace. +var replicaRegionPairs = { + australiaeast: 'australiasoutheast' + centralus: 'westus' + eastasia: 'japaneast' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'eastasia' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +var replicaLocation = replicaRegionPairs[resourceGroup().location] + +// ========== User Assigned Identity ========== // +// WAF best practices for identity and access management: https://learn.microsoft.com/en-us/azure/well-architected/security/identity-access +var userAssignedIdentityResourceName = 'id-${solutionSuffix}' +module appIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { + name: take('avm.res.managed-identity.user-assigned-identity.${userAssignedIdentityResourceName}', 64) + params: { + name: userAssignedIdentityResourceName + location: solutionLocation + tags: allTags + enableTelemetry: enableTelemetry + } +} + +// ========== Log Analytics Workspace ========== // +// WAF best practices for Log Analytics: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-log-analytics +// WAF PSRules for Log Analytics: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#azure-monitor-logs +var logAnalyticsWorkspaceResourceName = 'log-${solutionSuffix}' +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.12.0' = if ((enableMonitoring || enablePrivateNetworking) && !useExistingLogAnalytics) { + name: take('avm.res.operational-insights.workspace.${logAnalyticsWorkspaceResourceName}', 64) + params: { + name: logAnalyticsWorkspaceResourceName + location: solutionLocation + skuName: 'PerGB2018' + dataRetention: 30 + diagnosticSettings: [{ useThisWorkspace: true }] + tags: allTags + enableTelemetry: enableTelemetry + features: { enableLogAccessUsingOnlyResourcePermissions: true } + // WAF aligned configuration for Redundancy + dailyQuotaGb: enableRedundancy ? 10 : null //WAF recommendation: 10 GB per day is a good starting point for most workloads + replication: enableRedundancy + ? { + enabled: true + location: replicaLocation + } + : null + // WAF aligned configuration for Private Networking + publicNetworkAccessForIngestion: enablePrivateNetworking ? 'Disabled' : 'Enabled' + publicNetworkAccessForQuery: enablePrivateNetworking ? 'Disabled' : 'Enabled' + dataSources: enablePrivateNetworking + ? [ + { + tags: allTags + eventLogName: 'Application' + eventTypes: [ + { + eventType: 'Error' + } + { + eventType: 'Warning' + } + { + eventType: 'Information' + } + ] + kind: 'WindowsEvent' + name: 'applicationEvent' + } + { + counterName: '% Processor Time' + instanceName: '*' + intervalSeconds: 60 + kind: 'WindowsPerformanceCounter' + name: 'windowsPerfCounter1' + objectName: 'Processor' + } + { + kind: 'IISLogs' + name: 'sampleIISLog1' + state: 'OnPremiseEnabled' + } + ] + : null + } +} + +// ========== Application Insights ========== // +// WAF best practices for Application Insights: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/application-insights +// WAF PSRules for Application Insights: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#application-insights +var applicationInsightsResourceName = 'appi-${solutionSuffix}' +module applicationInsights 'br/public:avm/res/insights/component:0.6.0' = if (enableMonitoring) { + name: take('avm.res.insights.component.${applicationInsightsResourceName}', 64) + #disable-next-line no-unnecessary-dependson + //dependsOn: [logAnalyticsWorkspace] + params: { + name: applicationInsightsResourceName + location: solutionLocation + tags: allTags + enableTelemetry: enableTelemetry + retentionInDays: 365 + kind: 'web' + disableIpMasking: false + flowType: 'Bluefield' + // WAF aligned configuration for Monitoring + workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null + } +} + +// ========== Virtual Network ========== // +module virtualNetwork './modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + name: take('module.virtual-network.${solutionSuffix}', 64) + params: { + name: 'vnet-${solutionSuffix}' + addressPrefixes: ['10.0.0.0/20'] + location: location + tags: allTags + logAnalyticsWorkspaceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + resourceSuffix: solutionSuffix + enableTelemetry: enableTelemetry + } +} + +// Azure Bastion Host +var bastionHostName = 'bas-${solutionSuffix}' // Bastion host name must be between 3 and 15 characters in length and use numbers and lower-case letters only. +module bastionHost 'br/public:avm/res/network/bastion-host:0.6.1' = if (enablePrivateNetworking) { + name: take('avm.res.network.bastion-host.${bastionHostName}', 64) + params: { + name: bastionHostName + skuName: 'Standard' + location: location + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + diagnosticSettings: enableMonitoring + ? [ + { + name: 'bastionDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + } + ] + : null + tags: allTags + enableTelemetry: enableTelemetry + publicIPAddressObject: { + name: 'pip-${bastionHostName}' + zones: [] + } + } +} +// Jumpbox Virtual Machine +var jumpboxVmName = take('vm-jumpbox-${solutionSuffix}', 15) +module jumpboxVM 'br/public:avm/res/compute/virtual-machine:0.15.0' = if (enablePrivateNetworking) { + name: take('avm.res.compute.virtual-machine.${jumpboxVmName}', 64) + params: { + name: take(jumpboxVmName, 15) // Shorten VM name to 15 characters to avoid Azure limits + vmSize: vmSize ?? 'Standard_DS2_v2' + location: location + adminUsername: vmAdminUsername ?? 'JumpboxAdminUser' + adminPassword: vmAdminPassword ?? 'JumpboxAdminP@ssw0rd1234!' + tags: allTags + zone: 0 + imageReference: { + offer: 'WindowsServer' + publisher: 'MicrosoftWindowsServer' + sku: '2019-datacenter' + version: 'latest' + } + osType: 'Windows' + osDisk: { + name: 'osdisk-${jumpboxVmName}' + managedDisk: { + storageAccountType: 'Standard_LRS' + } + } + encryptionAtHost: false // Some Azure subscriptions do not support encryption at host + nicConfigurations: [ + { + name: 'nic-${jumpboxVmName}' + ipConfigurations: [ + { + name: 'ipconfig1' + subnetResourceId: virtualNetwork!.outputs.jumpboxSubnetResourceId + } + ] + diagnosticSettings: enableMonitoring + ? [ + { + name: 'jumpboxDiagnostics' + workspaceResourceId: logAnalyticsWorkspaceResourceId + logCategoriesAndGroups: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metricCategories: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } + ] + : null + } + ] + enableTelemetry: enableTelemetry + } +} + +var processBlobContainerName = 'processes' +var processQueueName = 'processes-queue' + +// ========== Private DNS Zones ========== // +var privateDnsZones = [ + 'privatelink.cognitiveservices.azure.com' + 'privatelink.openai.azure.com' + 'privatelink.services.ai.azure.com' + 'privatelink.documents.azure.com' + 'privatelink.blob.${environment().suffixes.storage}' + 'privatelink.queue.${environment().suffixes.storage}' + 'privatelink.azconfig.io' +] + +// DNS Zone Index Constants +var dnsZoneIndex = { + cognitiveServices: 0 + openAI: 1 + aiServices: 2 + cosmosDB: 3 + storageBlob: 4 + storageQueue: 5 + appConfig: 6 +} + +// List of DNS zone indices that correspond to AI-related services. +var aiRelatedDnsZoneIndices = [ + dnsZoneIndex.cognitiveServices + dnsZoneIndex.openAI + dnsZoneIndex.aiServices +] + +// =================================================== +// DEPLOY PRIVATE DNS ZONES +// - Deploys all zones if no existing Foundry project is used +// - Excludes AI-related zones when using with an existing Foundry project +// =================================================== +@batchSize(5) +module avmPrivateDnsZones 'br/public:avm/res/network/private-dns-zone:0.7.1' = [ + for (zone, i) in privateDnsZones: if (enablePrivateNetworking && (empty(existingFoundryProjectResourceId) || !contains( + aiRelatedDnsZoneIndices, + i + ))) { + name: 'dns-zone-${i}' + params: { + name: zone + tags: allTags + enableTelemetry: enableTelemetry + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetwork!.outputs.name}-${split(zone, '.')[1]}', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } + } +] + +// ========== AVM WAF ========== // +// ========== Storage account module ========== // +var storageAccountName = 'st${solutionSuffix}' // Storage account name must be between 3 and 24 characters in length and use numbers and lower-case letters only. +module storageAccount 'br/public:avm/res/storage/storage-account:0.20.0' = { + name: take('avm.res.storage.storage-account.${storageAccountName}', 64) + params: { + name: storageAccountName + location: solutionLocation + managedIdentities: { systemAssigned: true } + minimumTlsVersion: 'TLS1_2' + enableTelemetry: enableTelemetry + tags: allTags + accessTier: 'Hot' + supportsHttpsTrafficOnly: true + roleAssignments: [ + { + roleDefinitionIdOrName: 'Storage Blob Data Contributor' + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: 'Storage Queue Data Contributor' + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + ] + // WAF aligned networking + networkAcls: { + bypass: 'AzureServices' + defaultAction: enablePrivateNetworking ? 'Deny' : 'Allow' + } + allowBlobPublicAccess: enablePrivateNetworking ? true : false + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + // Private endpoints for blob and queue + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-storage-${storageAccountName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-blob' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageBlob]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + service: 'blob' + } + { + name: 'pep-queue-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'storage-dns-zone-group-queue' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.storageQueue]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + service: 'queue' + } + ] + : [] + blobServices: { + corsRules: [] + deleteRetentionPolicyEnabled: false + containers: [ + { + name: 'data' + publicAccess: 'None' + denyEncryptionScopeOverride: false + defaultEncryptionScope: '$account-encryption-key' + } + ] + } + queueServices: { + deleteRetentionPolicyEnabled: true + deleteRetentionPolicyDays: 7 + queues: [ + for queue in ([processQueueName, '${processQueueName}-dead-letter'] ?? []): { + name: queue + } + ] + } + } +} + +//========== AVM WAF ========== // +//========== Cosmos DB module ========== // +var cosmosDbResourceName = 'cosmos-${solutionSuffix}' +var cosmosDbZoneRedundantHaRegionPairs = { + australiaeast: 'uksouth' //'southeastasia' + centralus: 'eastus2' + eastasia: 'southeastasia' + eastus: 'centralus' + eastus2: 'centralus' + japaneast: 'australiaeast' + northeurope: 'westeurope' + southeastasia: 'eastasia' + uksouth: 'westeurope' + westeurope: 'northeurope' +} +var cosmosDbHaLocation = cosmosDbZoneRedundantHaRegionPairs[resourceGroup().location] + +var cosmosDatabaseName = 'migration_db' +var processCosmosContainerName = 'processes' +var agentTelemetryCosmosContainerName = 'agent_telemetry' +module cosmosDb 'br/public:avm/res/document-db/database-account:0.15.0' = { + name: take('avm.res.document-db.database-account.${cosmosDbResourceName}', 64) + params: { + name: cosmosDbResourceName + location: cosmosLocation + tags: allTags + enableTelemetry: enableTelemetry + sqlDatabases: [ + { + name: cosmosDatabaseName + containers: [ + { + name: processCosmosContainerName + paths: [ + '/_partitionKey' + ] + } + { + name: agentTelemetryCosmosContainerName + paths: [ + '/_partitionKey' + ] + } + { + name: 'files' + paths: [ + '/_partitionKey' + ] + } + { + name: 'process_statuses' + paths: [ + '/_partitionKey' + ] + } + ] + } + ] + + diagnosticSettings: enableMonitoring + ? [ + { + workspaceResourceId: logAnalyticsWorkspaceResourceId + } + ] + : null + + networkRestrictions: { + networkAclBypass: 'None' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + } + + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-${cosmosDbResourceName}' + customNetworkInterfaceName: 'nic-${cosmosDbResourceName}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cosmosDB]!.outputs.resourceId } + ] + } + service: 'Sql' + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + } + ] + : [] + + zoneRedundant: enableRedundancy ? true : false + capabilitiesToAdd: enableRedundancy + ? null + : [ + 'EnableServerless' + ] + automaticFailover: enableRedundancy ? true : false + failoverLocations: enableRedundancy + ? [ + { + failoverPriority: 0 + isZoneRedundant: true + locationName: solutionLocation + } + { + failoverPriority: 1 + isZoneRedundant: true + locationName: cosmosDbHaLocation + } + ] + : [ + { + locationName: solutionLocation + failoverPriority: 0 + isZoneRedundant: enableRedundancy + } + ] + // Use built-in Cosmos DB roles for RBAC access + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'DocumentDB Account Contributor' + } + ] + // Create custom data plane role definition and assignment + dataPlaneRoleDefinitions: [ + { + roleName: 'CosmosDB Data Contributor Custom' + dataActions: [ + 'Microsoft.DocumentDB/databaseAccounts/readMetadata' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/executeQuery' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/readChangeFeed' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' + 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' + ] + assignments: [ + { principalId: appIdentity.outputs.principalId } + // ADD THIS for local debugging support: + { principalId: deployingUserPrincipalId } + ] + } + ] + } + dependsOn: [storageAccount] +} + + +// ========== Container Registry for developer builds ========== // +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') +module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { + name: 'registryDeployment' + params: { + name: 'cr${solutionSuffix}' + acrAdminUserEnabled: false + acrSku: 'Basic' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + location: solutionLocation + softDeletePolicyDays: 7 + softDeletePolicyStatus: 'disabled' + tags: allTags + networkRuleBypassOptions: 'AzureServices' + roleAssignments: [ + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: appIdentity.outputs.principalId + } + ] + } +} + +var aiModelDeploymentName = aiModelName + +var useExistingAiFoundryAiProject = !empty(existingFoundryProjectResourceId) +var aiFoundryAiServicesResourceGroupName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[4] + : 'rg-${solutionSuffix}' +var aiFoundryAiServicesSubscriptionId = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[2] + : subscription().id +var aiFoundryAiServicesResourceName = useExistingAiFoundryAiProject + ? split(existingFoundryProjectResourceId, '/')[8] + : 'aif-${solutionSuffix}' + + +resource existingAiFoundryAiServices 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (useExistingAiFoundryAiProject) { + name: aiFoundryAiServicesResourceName + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +module existingAiFoundryAiServicesDeployments 'modules/ai-services-deployments.bicep' = if (useExistingAiFoundryAiProject) { + name: take('module.ai-services-model-deployments.${existingAiFoundryAiServices.name}', 64) + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) + params: { + name: existingAiFoundryAiServices.name + deployments: [ + { + name: aiModelDeploymentName + model: { + format: 'OpenAI' + name: aiModelName + version: aiModelVersion + } + sku: { + name: aiDeploymentType + capacity: aiModelCapacity + } + } + ] + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + } + ] + } +} + +// Temporarily disabled AI Foundry due to AML workspace creation issues +module aiFoundry 'br/public:avm/ptn/ai-ml/ai-foundry:0.4.0' = if(!useExistingAiFoundryAiProject) { + name: take('avm.ptn.ai-ml.ai-foundry.${solutionSuffix}', 64) + params: { + #disable-next-line BCP334 + baseName: take(aiFoundryAiServicesResourceName, 12) + baseUniqueName: null + location: empty(azureAiServiceLocation) ? location : azureAiServiceLocation + aiFoundryConfiguration: { + accountName:aiFoundryAiServicesResourceName + allowProjectManagement: true + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Cognitive Services OpenAI Contributor' + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '64702f94-c441-49e6-a78b-ef80e0188fee' // Azure AI Developer + } + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: '53ca6127-db72-4b80-b1b0-d745d6d5456d' // Azure AI User + } + ] + // Remove networking configuration to avoid AML workspace creation issues + networking: enablePrivateNetworking? { + aiServicesPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.aiServices]!.outputs.resourceId + openAiPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.openAI]!.outputs.resourceId + cognitiveServicesPrivateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.cognitiveServices]!.outputs.resourceId + } : null + } + // Disable private endpoints temporarily to fix AML workspace issue + privateEndpointSubnetResourceId: enablePrivateNetworking ? virtualNetwork!.outputs.backendSubnetResourceId : null + // Only attempt model deployment when explicitly enabled to avoid AccountIsNotSucceeded failures due to quota or model availability. + aiModelDeployments: [ + { + name: aiModelDeploymentName + model: { + format: 'OpenAI' + name: aiModelName + version: aiModelVersion + } + sku: { + name: aiDeploymentType + capacity: aiModelCapacity + } + } + ] + tags: allTags + enableTelemetry: enableTelemetry + } +} + +// User Role Assignment for Azure OpenAI - New Resources +module userOpenAiRoleAssignment './modules/role.bicep' = if (!useExistingAiFoundryAiProject) { + name: take('user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}', 64) + params: { + name: 'user-openai-${uniqueString(deployingUserPrincipalId, aiFoundryAiServicesResourceName)}' + principalId: deployingUserPrincipalId + aiServiceName: aiFoundryAiServicesResourceName + principalType: 'User' + } +} + +// User Role Assignment for Azure OpenAI - Existing Resources +module userOpenAiRoleAssignmentExisting './modules/role.bicep' = if (useExistingAiFoundryAiProject) { + name: take('user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}', 64) + params: { + name: 'user-openai-existing-${uniqueString(deployingUserPrincipalId, existingAiFoundryAiServices.name)}' + principalId: deployingUserPrincipalId + aiServiceName: existingAiFoundryAiServices.name + principalType: 'User' + } + scope: resourceGroup(aiFoundryAiServicesSubscriptionId, aiFoundryAiServicesResourceGroupName) +} + +var aiServicesName = useExistingAiFoundryAiProject ? existingAiFoundryAiServices.name : aiFoundryAiServicesResourceName +module appConfiguration 'br/public:avm/res/app-configuration/configuration-store:0.9.1' = { + name: take('avm.res.app-config.store.${solutionSuffix}', 64) + params: { + location: solutionLocation + name: 'appcs-${solutionSuffix}' + disableLocalAuth: false // needed to allow setting app config key values from this module + tags: allTags + // Always set key values during deployment since Container Apps will be in private network + keyValues: [ + { + name: 'APP_LOGGING_ENABLE' + value: 'true' + } + { + name: 'APP_LOGGING_LEVEL' + value: 'INFO' + } + { + name: 'AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME' + value: '' + } + { + name: 'AZURE_AI_AGENT_PROJECT_CONNECTION_STRING' + value: '' + } + { + name: 'AZURE_OPENAI_API_VERSION' + value: '2025-01-01-preview' + } + { + name: 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' + value: aiModelDeploymentName + } + { + name: 'AZURE_OPENAI_ENDPOINT' + value: 'https://${aiServicesName}.cognitiveservices.azure.com/' + } + { + name: 'AZURE_OPENAI_ENDPOINT_BASE' + value: 'https://${aiServicesName}.cognitiveservices.azure.com/' + } + { + name: 'AZURE_TRACING_ENABLED' + value: 'True' + } + { + name: 'STORAGE_ACCOUNT_BLOB_URL' + value: 'https://${storageAccountName}.blob.${environment().suffixes.storage}' + } + { + name: 'STORAGE_ACCOUNT_NAME' + value: storageAccount.outputs.name + } + { + name: 'STORAGE_ACCOUNT_PROCESS_CONTAINER' + value: processBlobContainerName + } + { + name: 'STORAGE_ACCOUNT_PROCESS_QUEUE' + value: processQueueName + } + { + name: 'STORAGE_ACCOUNT_QUEUE_URL' + value: 'https://${storageAccountName}.queue.${environment().suffixes.storage}' + } + { + name: 'COSMOS_DB_CONTAINER_NAME' + value: agentTelemetryCosmosContainerName + } + { + name: 'COSMOS_DB_DATABASE_NAME' + value: cosmosDatabaseName + } + { + name: 'COSMOS_DB_ACCOUNT_URL' + value: cosmosDb.outputs.endpoint + } + { + name: 'COSMOS_DB_PROCESS_CONTAINER' + value: processCosmosContainerName + } + { + name: 'COSMOS_DB_PROCESS_LOG_CONTAINER' + value: agentTelemetryCosmosContainerName + } + { + name: 'GLOBAL_LLM_SERVICE' + value: 'AzureOpenAI' + } + { + name: 'STORAGE_QUEUE_ACCOUNT' + value: storageAccount.outputs.name + } + ] + roleAssignments: [ + { + principalId: appIdentity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'App Configuration Data Reader' + } + ] + enableTelemetry: enableTelemetry + managedIdentities: { systemAssigned: true } + sku: 'Standard' + publicNetworkAccess: 'Enabled' + } +} + +module avmAppConfigUpdated 'br/public:avm/res/app-configuration/configuration-store:0.6.3' = if (enablePrivateNetworking) { + name: take('avm.res.app-configuration.configuration-store-update.${solutionSuffix}', 64) + params: { + name: 'appcs-${solutionSuffix}' + location: solutionLocation + managedIdentities: { systemAssigned: true } + sku: 'Standard' + enableTelemetry: enableTelemetry + tags: allTags + disableLocalAuth: true + // Keep public access enabled for Container Apps access (Container Apps not in private network due to capacity constraints) + publicNetworkAccess: 'Enabled' + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-appconfig-${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { + name: 'appconfig-dns-zone-group' + privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.appConfig]!.outputs.resourceId + } + ] + } + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + } + ] + : [] + } + dependsOn: [ + appConfiguration + ] +} + +var logAnalyticsPrimarySharedKey = useExistingLogAnalytics + ? existingLogAnalyticsWorkspace!.listKeys().primarySharedKey + : logAnalyticsWorkspace!.outputs!.primarySharedKey +var logAnalyticsWorkspaceId = useExistingLogAnalytics + ? existingLogAnalyticsWorkspace!.properties.customerId + : logAnalyticsWorkspace!.outputs.logAnalyticsWorkspaceId +// ========== Container App Environment ========== // +module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.11.2' = { + name: take('avm.res.app.managed-environment.${solutionSuffix}', 64) + params: { + name: 'cae-${solutionSuffix}' + location: location + managedIdentities: { systemAssigned: true } + appLogsConfiguration: enableMonitoring + ? { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspaceId + sharedKey: logAnalyticsPrimarySharedKey + } + } + : null + workloadProfiles: [ + { + name: 'Consumption' + workloadProfileType: 'Consumption' + } + ] + enableTelemetry: enableTelemetry + publicNetworkAccess: 'Enabled' // Always enabled for Container Apps Environment + + // <========== WAF related parameters + + platformReservedCidr: '172.17.17.0/24' + platformReservedDnsIP: '172.17.17.17' + zoneRedundant: (enablePrivateNetworking) ? true : false // Enable zone redundancy if private networking is enabled + infrastructureSubnetResourceId: (enablePrivateNetworking) + ? virtualNetwork!.outputs.containersSubnetResourceId // Use the container app subnet + : null // Use the container app subnet + } +} + +var backendContainerPort = 80 +var backendContainerAppName = take('ca-backend-api-${solutionSuffix}', 32) +module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${backendContainerAppName}', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] + params: { + name: backendContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'backend' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'backend-api' + image: !empty(backendImageName) ? backendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: concat( + [ + { + name: 'APP_CONFIGURATION_URL' + value: appConfiguration.outputs.endpoint + } + { + name: 'AZURE_CLIENT_ID' + value: appIdentity.outputs.clientId + } + ], + enableMonitoring + ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights!.outputs.connectionString + } + ] + : [] + ) + resources: { + cpu: 1 + memory: '2.0Gi' + } + } + ] + ingressTargetPort: backendContainerPort + ingressExternal: true + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + rules: enableScalability + ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } + } + ] + : [] + } + corsPolicy: { + allowedOrigins: [ + '*' + ] + allowedMethods: [ + 'GET' + 'POST' + 'PUT' + 'DELETE' + 'OPTIONS' + ] + allowedHeaders: [ + 'Authorization' + 'Content-Type' + '*' + ] + } + enableTelemetry: enableTelemetry + } +} + +var frontEndContainerAppName = take('ca-frontend-${solutionSuffix}', 32) +module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${frontEndContainerAppName}', 64) + params: { + name: frontEndContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'frontend' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'frontend' + image: !empty(frontendImageName) ? frontendImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: [ + { + name: 'API_URL' + value: 'https://${containerAppBackend.outputs.fqdn}' + } + { + name: 'APP_ENV' + value: 'prod' + } + ] + resources: { + cpu: '1' + memory: '2.0Gi' + } + } + ] + ingressTargetPort: 3000 + ingressExternal: true + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + rules: enableScalability + ? [ + { + name: 'http-scaler' + http: { + metadata: { + concurrentRequests: 100 + } + } + } + ] + : [] + } + enableTelemetry: enableTelemetry + } +} + +var processorContainerAppName = take('ca-processor-${solutionSuffix}', 32) +module containerAppProcessor 'br/public:avm/res/app/container-app:0.18.1' = { + name: take('avm.res.app.container-app.${processorContainerAppName}', 64) + #disable-next-line no-unnecessary-dependson + dependsOn: [applicationInsights] + params: { + name: processorContainerAppName + location: solutionLocation + environmentResourceId: containerAppsEnvironment.outputs.resourceId + tags: union(allTags, { 'azd-service-name': 'processor' }) + managedIdentities: { + userAssignedResourceIds: [ + appIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: appIdentity.outputs.resourceId + } + ] + containers: [ + { + name: 'processor' + image: !empty(processorImageName) ? processorImageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: concat( + [ + { + name: 'APP_CONFIGURATION_URL' + value: appConfiguration.outputs.endpoint + } + { + name: 'AZURE_CLIENT_ID' + value: appIdentity.outputs.clientId + } + { + name: 'AZURE_STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed or if pulled from app config service + value: storageAccount.outputs.name + } + { + name: 'STORAGE_ACCOUNT_NAME' // TODO - verify name and if needed + value: storageAccount.outputs.name + } + ], + enableMonitoring + ? [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights!.outputs.connectionString + } + ] + : [] + ) + resources: { + // TODO - assess increasing resource limits + cpu: 2 + memory: '4.0Gi' + } + } + ] + ingressTransport: null + disableIngress: true + ingressExternal: false + scaleSettings: { + maxReplicas: enableScalability ? 3 : 1 + minReplicas: 1 + //rules: [] - TODO - what scaling rules to use here? + } + enableTelemetry: enableTelemetry + } +} + +@description('The name of the resource group.') +output resourceGroupName string = resourceGroup().name + +@description('The name of the frontend container app.') +output CONTAINER_FRONTEND_APP_NAME string = containerAppFrontend.outputs.name + +@description('The FQDN of the frontend container app.') +output CONTAINER_FRONTEND_APP_FQDN string = containerAppFrontend.outputs.fqdn + +// Keep these for backward compatibility if needed +@description('The name of the web app container app.') +output CONTAINER_WEB_APP_NAME string = containerAppFrontend.outputs.name + +@description('The FQDN of the web app container app.') +output CONTAINER_WEB_APP_FQDN string = containerAppFrontend.outputs.fqdn + +@description('The name of the API container app.') +output CONTAINER_API_APP_NAME string = containerAppBackend.outputs.name + +@description('The FQDN of the API container app.') +output CONTAINER_API_APP_FQDN string = containerAppBackend.outputs.fqdn + +@description('The Azure subscription ID.') +output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId + +@description('The Azure resource group name.') +output AZURE_RESOURCE_GROUP string = resourceGroup().name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +@description('Backend service container app name') +output SERVICE_BACKEND_NAME string = containerAppBackend.outputs.name + +@description('Backend service container app URI') +output SERVICE_BACKEND_URI string = 'https://${containerAppBackend.outputs.fqdn}' + +@description('Processor service container app name') +output SERVICE_PROCESSOR_NAME string = containerAppProcessor.outputs.name + +@description('Frontend service container app name') +output SERVICE_FRONTEND_NAME string = containerAppFrontend.outputs.name + +@description('Frontend service container app URI') +output SERVICE_FRONTEND_URI string = 'https://${containerAppFrontend.outputs.fqdn}' diff --git a/infra/modules/role.bicep b/infra/modules/role.bicep new file mode 100644 index 0000000..79ff04e --- /dev/null +++ b/infra/modules/role.bicep @@ -0,0 +1,60 @@ +@description('The name of the role assignment.') +param name string + +@description('The principal ID to grant access to.') +param principalId string + +@description('The name of the existing Azure Cognitive Services account.') +param aiServiceName string + +@allowed(['Device', 'ForeignGroup', 'Group', 'ServicePrincipal', 'User']) +param principalType string = 'ServicePrincipal' + +resource cognitiveServiceExisting 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiServiceName +} + +// Cognitive Services OpenAI User +resource cognitiveServiceOpenAIUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' +} + +// Azure AI Developer +resource aiDeveloper 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '64702f94-c441-49e6-a78b-ef80e0188fee' +} + +// Azure AI Inference Deployment Operator +resource aiUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' +} + +resource aiUserAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-aiuser-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: aiUser.id + principalId: principalId + principalType: principalType + } +} + +resource aiDeveloperAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-aidev-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: aiDeveloper.id + principalId: principalId + principalType: principalType + } +} + +resource cognitiveServiceOpenAIUserAccessFoundry 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid('${name}-openai-${principalId}') + scope: cognitiveServiceExisting + properties: { + roleDefinitionId: cognitiveServiceOpenAIUser.id + principalId: principalId + principalType: principalType + } +} diff --git a/src/frontend/src/components/uploadButton.tsx b/src/frontend/src/components/uploadButton.tsx index 9be1bb4..345bba5 100644 --- a/src/frontend/src/components/uploadButton.tsx +++ b/src/frontend/src/components/uploadButton.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState, useEffect } from 'react'; import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone'; -import { CircleCheck, X, Lock } from 'lucide-react'; +import { CircleCheck, X, CircleX } from 'lucide-react'; import { Button, Toast, @@ -54,11 +54,125 @@ const FileUploadZone: React.FC = ({ const [fileLimitExceeded, setFileLimitExceeded] = useState(false); const [showFileLimitDialog, setShowFileLimitDialog] = useState(false); const [isCreatingProcess, setIsCreatingProcess] = useState(false); + const [rejectedFiles, setRejectedFiles] = useState([]); + const [showFileRejectionError, setShowFileRejectionError] = useState(false); + const [showNetworkError, setShowNetworkError] = useState(false); + const [networkErrorMessage, setNetworkErrorMessage] = useState(''); const navigate = useNavigate(); const MAX_FILES = 20; const dispatch = useDispatch(); + // Helper function to detect network connectivity issues + const isNetworkError = (error: any): boolean => { + // Always check navigator.onLine first - most reliable indicator + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + console.log('Network error detected: navigator.onLine is false'); + return true; + } + + // Check for common network error patterns + if (error && typeof error === 'object') { + const errorMessage = error.message?.toLowerCase() || ''; + const errorName = error.name?.toLowerCase() || ''; + const errorString = error.toString?.()?.toLowerCase() || ''; + + // Log the error for debugging + console.log('Checking error for network issues:', { + message: errorMessage, + name: errorName, + status: error.status, + code: error.code, + type: error.type, + fullError: error + }); + + // Check various network error indicators + const networkKeywords = [ + 'network', 'fetch', 'connection', 'timeout', 'offline', 'unreachable', + 'cors', 'net::', 'failed to fetch', 'load failed', 'network request failed', + 'connection refused', 'connection reset', 'no internet', 'dns' + ]; + + const hasNetworkKeyword = networkKeywords.some(keyword => + errorMessage.includes(keyword) || + errorName.includes(keyword) || + errorString.includes(keyword) + ); + + const isNetworkStatus = ( + error.status === 0 || // No response from server (common for network issues) + error.status === 408 || // Request timeout + error.status === 504 || // Gateway timeout + error.status === 502 || // Bad gateway + error.status === 503 // Service unavailable + ); + + const isNetworkCode = ( + error.code === 'NETWORK_ERROR' || + error.code === 'TIMEOUT' || + error.code === 'ENOTFOUND' || + error.code === 'ECONNREFUSED' || + error.code === 'ECONNRESET' + ); + + if (hasNetworkKeyword || isNetworkStatus || isNetworkCode) { + console.log('Network error detected:', { hasNetworkKeyword, isNetworkStatus, isNetworkCode }); + return true; + } + } + + // If error is a simple string, check for network keywords + if (typeof error === 'string') { + const errorLower = error.toLowerCase(); + if (errorLower.includes('network') || errorLower.includes('connection') || errorLower.includes('fetch')) { + console.log('Network error detected in string:', error); + return true; + } + } + + return false; + }; + + const getNetworkErrorMessage = (): string => { + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + return 'Network connection lost. Please check your internet connection and try again.'; + } + return 'Network error occurred during upload. Please check your connection and retry.'; + }; + + // Helper function to create user-friendly error messages for file rejections + const getFileRejectionMessage = (rejections: FileRejection[]): { title: string; details: string } => { + if (rejections.length === 0) return { title: '', details: '' }; + + // Check the rejection reasons + const hasInvalidType = rejections.some(r => r.errors.some(e => e.code === 'file-invalid-type')); + const hasLargeFile = rejections.some(r => r.errors.some(e => e.code === 'file-too-large')); + + let title = ''; + let details = ''; + + if (hasInvalidType && hasLargeFile) { + title = 'Invalid files detected. Please check file type and size.'; + } else if (hasInvalidType) { + title = 'Invalid file type. Please upload only .yaml or .yml files.'; + } else if (hasLargeFile) { + title = `File too large. Maximum size is ${Math.floor(maxSize / (1024 * 1024))}MB per file.`; + } else { + title = 'File upload rejected. Please check your files.'; + } + + // Create details message + if (rejections.length === 1) { + details = `File rejected: ${rejections[0].file.name}`; + } else { + const fileNames = rejections.map(r => r.file.name).slice(0, 3); + details = `${rejections.length} files rejected: ${fileNames.join(', ')}${rejections.length > 3 ? '...' : ''}`; + } + + return { title, details }; + }; + useEffect(() => { if (uploadingFiles.length === 0) { setAllUploadsComplete(false); @@ -70,6 +184,19 @@ const FileUploadZone: React.FC = ({ if (uploadingFiles.length > 0) { const activeFiles = uploadingFiles.filter(f => f.status !== 'error'); + const errorFiles = uploadingFiles.filter(f => f.status === 'error'); + + // Only check for network errors if there are actual upload errors and no file rejection errors are being shown + // This prevents showing network error when the issue is just wrong file type/extension + if (errorFiles.length > 0 && !showNetworkError && !showFileRejectionError) { + // Check if navigator is offline + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + console.log('Network error detected in useEffect: navigator.onLine is false'); + setNetworkErrorMessage(getNetworkErrorMessage()); + setShowNetworkError(true); + } + } + if (activeFiles.length > 0 && activeFiles.every(f => f.status === 'completed')) { newState = 'COMPLETED'; setAllUploadsComplete(true); @@ -80,7 +207,7 @@ const FileUploadZone: React.FC = ({ setUploadState(newState); onUploadStateChange?.(newState); - }, [uploadingFiles, onUploadStateChange]); + }, [uploadingFiles, onUploadStateChange, showNetworkError, showFileRejectionError]); const simulateFileUploadWithProcessCreation = async (files: File[]) => { console.log("Starting process creation for files:", files.map(f => f.name)); @@ -211,12 +338,43 @@ const FileUploadZone: React.FC = ({ ) ); + // Clear any file rejection errors when files are successfully uploaded + if (showFileRejectionError) { + setShowFileRejectionError(false); + setRejectedFiles([]); + } + // Files uploaded successfully - user can now click "Start processing" button console.log("Files uploaded successfully. User can now click 'Start processing' to begin processing."); } catch (uploadError) { // Clear upload interval immediately on upload error clearInterval(uploadIntervalId); + // Add comprehensive error logging + console.error('Upload error details:', { + error: uploadError, + message: uploadError?.message, + name: uploadError?.name, + status: uploadError?.status, + code: uploadError?.code, + navigatorOnline: navigator?.onLine + }); + + // Check if this is a network error and show network error message + const isNetworkIssue = isNetworkError(uploadError); + console.log('Is network error?', isNetworkIssue); + + if (isNetworkIssue) { + setNetworkErrorMessage(getNetworkErrorMessage()); + setShowNetworkError(true); + console.log('Network error detected - showing MessageBar'); + } else { + // For now, treat ALL upload errors as potential network issues + console.log('Treating upload error as potential network issue'); + setNetworkErrorMessage('Upload failed. This might be due to network connectivity issues. Please try again.'); + setShowNetworkError(true); + } + // Mark files as error setUploadingFiles(prev => prev.map(f => @@ -233,6 +391,12 @@ const FileUploadZone: React.FC = ({ if (createIntervalId) clearInterval(createIntervalId); if (uploadIntervalId) clearInterval(uploadIntervalId); + // Check if this is a network error + if (isNetworkError(error)) { + setNetworkErrorMessage(getNetworkErrorMessage()); + setShowNetworkError(true); + } + // Mark all files as error setUploadingFiles(prev => prev.map(f => @@ -313,10 +477,41 @@ const FileUploadZone: React.FC = ({ : f ) ); + + // Clear any file rejection errors when files are successfully uploaded + if (showFileRejectionError) { + setShowFileRejectionError(false); + setRejectedFiles([]); + } } catch (uploadError) { // Clear interval immediately on upload error clearInterval(uploadIntervalId); + // Add comprehensive error logging + console.error('Additional files upload error details:', { + error: uploadError, + message: (uploadError as any)?.message, + name: (uploadError as any)?.name, + status: (uploadError as any)?.status, + code: (uploadError as any)?.code, + navigatorOnline: navigator?.onLine + }); + + // Check if this is a network error and show network error message + const isNetworkIssue = isNetworkError(uploadError); + console.log('Is network error for additional files?', isNetworkIssue); + + if (isNetworkIssue) { + setNetworkErrorMessage(getNetworkErrorMessage()); + setShowNetworkError(true); + console.log('Network error detected for additional files - showing MessageBar'); + } else { + // For now, treat ALL upload errors as potential network issues + console.log('Treating additional files upload error as potential network issue'); + setNetworkErrorMessage('Upload failed. This might be due to network connectivity issues. Please try again.'); + setShowNetworkError(true); + } + // Mark files as error setUploadingFiles(prev => prev.map(f => @@ -331,6 +526,13 @@ const FileUploadZone: React.FC = ({ console.error("Failed to upload additional files:", error); // Clear interval immediately when error occurs if (uploadIntervalId) clearInterval(uploadIntervalId); + + // Check if this is a network error + if (isNetworkError(error)) { + setNetworkErrorMessage(getNetworkErrorMessage()); + setShowNetworkError(true); + } + // Mark all new files as error with current progress setUploadingFiles(prev => prev.map(f => @@ -342,6 +544,22 @@ const FileUploadZone: React.FC = ({ const onDrop = useCallback( async (acceptedFiles: File[], fileRejections: FileRejection[]) => { + // Handle file rejections first and return early if only rejected files + if (fileRejections.length > 0) { + setRejectedFiles(fileRejections); + setShowFileRejectionError(true); + + // Also call the optional external handler if provided + if (onFileReject) { + onFileReject(fileRejections); + } + + // If no files were accepted (all were rejected), return early to prevent any upload attempts + if (acceptedFiles.length === 0) { + return; + } + } + // Check current files count and determine how many more can be added const remainingSlots = MAX_FILES - uploadingFiles.length; @@ -381,10 +599,6 @@ const FileUploadZone: React.FC = ({ if (onFileUpload) onFileUpload(acceptedFiles); } - - if (onFileReject && fileRejections.length > 0) { - onFileReject(fileRejections); - } }, [onFileUpload, onFileReject, uploadingFiles.length, batchId] ); @@ -498,6 +712,44 @@ const FileUploadZone: React.FC = ({ } }, [uploadingFiles.length]); + // Add network connectivity listeners + useEffect(() => { + const handleOnline = () => { + console.log('Network came back online'); + // Optionally clear network error when connection is restored + if (showNetworkError) { + setShowNetworkError(false); + setNetworkErrorMessage(''); + } + }; + + const handleOffline = () => { + console.log('Network went offline'); + // Show network error immediately when network goes offline during uploads + if (uploadingFiles.some(f => f.status === 'uploading')) { + setNetworkErrorMessage('Network connection lost during upload. Please check your internet connection and retry.'); + setShowNetworkError(true); + + // Mark all uploading files as error + setUploadingFiles(prev => + prev.map(f => + f.status === 'uploading' ? { ...f, status: 'error' } : f + ) + ); + } + }; + + if (typeof window !== 'undefined') { + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + } + }, [uploadingFiles, showNetworkError]); + useEffect(() => { if (typeof window !== 'undefined') { const originalStartTranslating = (window as any).startTranslating; @@ -558,6 +810,19 @@ const FileUploadZone: React.FC = ({ } }, [fileLimitExceeded]); + // Auto-hide file rejection error after 8 seconds + useEffect(() => { + if (showFileRejectionError) { + const timer = setTimeout(() => { + setShowFileRejectionError(false); + setRejectedFiles([]); + }, 5000); + + return () => clearTimeout(timer); + } + }, [showFileRejectionError]); + + const handleStartProcessing = () => { if (uploadState === 'COMPLETED' && onStartTranslating) { onStartTranslating(); @@ -858,7 +1123,7 @@ const FileUploadZone: React.FC = ({ )} -
+
{allUploadsComplete && ( = ({ Maximum of {MAX_FILES} files allowed. Some files were not uploaded. )} + + {showFileRejectionError && rejectedFiles.length > 0 && (() => { + const errorMessages = getFileRejectionMessage(rejectedFiles); + return ( +
+ { + setShowFileRejectionError(false); + setRejectedFiles([]); + }} + dismissButtonAriaLabel="Close" + styles={{ + root: { display: "flex", alignItems: "flex-start", width: "100% !important" }, + icon: { display: "none" }, + }} + style={{ flex: 1, width: "100% !important" }} + > +
+ + {errorMessages.title} {errorMessages.details} +
+ {/*
+
+ {errorMessages.title} {errorMessages.details} +
+
+ {errorMessages.details} +
+
*/} +
+
+ ); + })()} + + {showNetworkError && ( +
+ { + setShowNetworkError(false); + setNetworkErrorMessage(''); + }} + dismissButtonAriaLabel="Close" + styles={{ + root: { display: "flex", alignItems: "flex-start", width: "100% !important" }, + icon: { display: "none" }, + }} + style={{ flex: 1, width: "100% !important" }} + > +
+
+ + {networkErrorMessage} +
+
+
+
+ )}
{uploadingFiles.length > 0 && (