diff --git a/apps/vs-code-designer/package.json b/apps/vs-code-designer/package.json index b24284c592f..95f4adb63af 100644 --- a/apps/vs-code-designer/package.json +++ b/apps/vs-code-designer/package.json @@ -6,7 +6,9 @@ "@azure/arm-appinsights": "^5.0.0-beta.7", "@azure/arm-appservice": "^15.0.0", "@azure/arm-resourcegraph": "^5.0.0-beta.3", + "@azure/arm-resources": "^7.0.0", "@azure/arm-storage": "^18.1.0", + "@azure/arm-subscriptions": "^6.0.0", "@azure/core-client": "^1.7.3", "@azure/core-rest-pipeline": "^1.11.0", "@azure/identity": "^4.5.0", diff --git a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts index f2ee57aa82c..53bbf5b21d3 100644 --- a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicApp.ts @@ -8,9 +8,54 @@ import type { SlotTreeItem } from '../../tree/slotsTree/SlotTreeItem'; import { SubscriptionTreeItem } from '../../tree/subscriptionTree/subscriptionTreeItem'; import { isString } from '@microsoft/logic-apps-shared'; import { callWithTelemetryAndErrorHandling, type AzExtParentTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { ICreateLogicAppContext } from '@microsoft/vscode-extension-logic-apps'; +import type { ICreateLogicAppContext, ILogicAppWizardContext } from '@microsoft/vscode-extension-logic-apps'; import { type MessageItem, window } from 'vscode'; +/** + * Creates a Logic App without showing wizard prompts - all required information must be provided in the context. + * @param context - Context with pre-filled values (newSiteName, location, resourceGroup or newResourceGroupName) + * @param subscription - Subscription ID or tree item + * @param skipNotification - If true, skips the completion notification + * @returns The created Logic App tree item + */ +export async function createLogicAppWithoutWizard( + context: IActionContext & Partial & { newSiteName: string; location: string }, + subscription: AzExtParentTreeItem | string, + skipNotification?: boolean +): Promise { + let node: AzExtParentTreeItem | undefined; + + if (isString(subscription)) { + node = await ext.rgApi.appResourceTree.findTreeItem(`/subscriptions/${subscription}`, context); + if (!node) { + throw new Error(localize('noMatchingSubscription', 'Failed to find a subscription matching id "{0}".', subscription)); + } + } else { + node = subscription; + } + + try { + // Call the subscription tree item's createChild with a special flag to skip prompts + const logicAppNode: SlotTreeItem = await SubscriptionTreeItem.createChildWithoutPrompts( + context as unknown as ICreateLogicAppContext & ILogicAppWizardContext, + node as SubscriptionTreeItem + ); + + if (!skipNotification) { + await notifyCreateLogicAppComplete(logicAppNode); + } + + // The node returned from creation may not have fully initialized fullId + // Look it up from the tree to ensure all properties are correctly set + const fullResourceId = `/subscriptions/${subscription}/resourceGroups/${context.newResourceGroupName ?? context.resourceGroup.name}/providers/Microsoft.Web/sites/${context.newSiteName}`; + const refetchedLogicAppNode = (await ext.rgApi.appResourceTree.findTreeItem(fullResourceId, context as IActionContext)) as SlotTreeItem; + + return refetchedLogicAppNode; + } catch (error) { + throw new Error(`Error in creating logic app. ${error}`); + } +} + export async function createLogicApp( context: IActionContext & Partial, subscription?: AzExtParentTreeItem | string, diff --git a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicAppSteps/logicAppCreateStep.ts b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicAppSteps/logicAppCreateStep.ts index 36c5d9c9210..74567546372 100644 --- a/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicAppSteps/logicAppCreateStep.ts +++ b/apps/vs-code-designer/src/app/commands/createLogicApp/createLogicAppSteps/logicAppCreateStep.ts @@ -67,6 +67,8 @@ export class LogicAppCreateStep extends AzureWizardExecuteStep | undefined, @@ -100,7 +100,27 @@ async function deploy( let node: SlotTreeItem; - if (expectedContextValue) { + // If functionAppId is a SlotTreeItem or LogicAppResourceTree, convert/use it directly + if (functionAppId && typeof functionAppId === 'object') { + const objWithConstructor = functionAppId as any; + if (objWithConstructor.constructor?.name === 'LogicAppResourceTree') { + // It's a LogicAppResourceTree, need to wrap it in SlotTreeItem + const resourceTree = functionAppId as any as LogicAppResourceTree; + const parentTreeItem = (resourceTree as any).parent; + if (!parentTreeItem) { + throw new Error('LogicAppResourceTree missing parent tree item'); + } + node = new SlotTreeItem(parentTreeItem, resourceTree); + } else if ('resourceTree' in objWithConstructor && 'site' in objWithConstructor) { + // It's already a SlotTreeItem + node = functionAppId as SlotTreeItem; + } else { + // Unknown object type, fall through to normal path + node = await getDeployNode(context, ext.rgApi.appResourceTree, target, functionAppId, async () => + getDeployLogicAppNode(actionContext) + ); + } + } else if (expectedContextValue) { node = await getDeployNode(context, ext.rgApi.appResourceTree, target, functionAppId, async () => ext.rgApi.pickAppResource( { ...context, suppressCreatePick: false }, diff --git a/apps/vs-code-designer/src/app/commands/deploy/deployWebview.ts b/apps/vs-code-designer/src/app/commands/deploy/deployWebview.ts new file mode 100644 index 00000000000..4eac3bcb73d --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/deploy/deployWebview.ts @@ -0,0 +1,151 @@ +import type { IActionContext, AzExtParentTreeItem } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; +import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; +import { ext } from '../../../extensionVariables'; +import { localize } from '../../../localize'; +import { createWorkspaceWebviewCommandHandler } from '../shared/workspaceWebviewCommandHandler'; +import type * as vscode from 'vscode'; +import { tryGetWebviewPanel } from '../../utils/codeless/common'; +import { getAuthorizationToken } from '../../utils/codeless/getAuthorizationToken'; +import { deploy } from './deploy'; +import { createLogicAppWithoutWizard } from '../createLogicApp/createLogicApp'; +import type { SlotTreeItem } from '../../tree/slotsTree/SlotTreeItem'; +import { getWebLocations, AppKind } from '@microsoft/vscode-azext-azureappservice'; +import type { SubscriptionTreeItem } from '../../tree/subscriptionTree/subscriptionTreeItem'; + +export async function deployViaWebview(context: IActionContext, target?: vscode.Uri): Promise { + // Get access token for Azure API calls + const accessToken = await getAuthorizationToken(); + const cloudHost = (context as any).environment?.name || 'AzureCloud'; + + await createWorkspaceWebviewCommandHandler({ + panelName: localize('deployToAzure', 'Deploy to Azure'), + panelGroupKey: ext.webViewKey.deploy, + projectName: ProjectName.deploy, + createCommand: ExtensionCommand.deploy, + createHandler: async (actionContext: IActionContext, data: any) => { + if (data.createNew) { + // Get subscription tree item to access environment info + const subscriptionNode = (await ext.rgApi.appResourceTree.findTreeItem( + `/subscriptions/${data.subscriptionId}`, + actionContext + )) as AzExtParentTreeItem; + + if (!subscriptionNode) { + throw new Error(localize('noMatchingSubscription', 'Failed to find subscription "{0}".', data.subscriptionId)); + } + + // Get subscription context from the tree item + const subscriptionTreeItem = subscriptionNode as SubscriptionTreeItem; + const subscriptionContext = subscriptionTreeItem.subscription; + + // User wants to create a new Logic App without wizard prompts + const createContext: any = { + ...actionContext, + ...subscriptionContext, // Include subscription context with environment info + newSiteName: data.newLogicAppName, + location: data.location, + newResourceGroupName: data.isCreatingNewResourceGroup ? data.resourceGroup : undefined, + resourceGroup: data.isCreatingNewResourceGroup ? undefined : { name: data.resourceGroup }, + newPlanName: data.isCreatingNewAppServicePlan ? data.appServicePlan : undefined, + plan: data.isCreatingNewAppServicePlan ? undefined : { id: data.appServicePlan }, + appServicePlanSku: data.appServicePlanSku || 'WS1', + newStorageAccountName: data.isCreatingNewStorageAccount ? data.storageAccount : undefined, + storageAccount: data.isCreatingNewStorageAccount ? undefined : { id: data.storageAccount }, + createAppInsights: data.createAppInsights, + newAppInsightsName: data.appInsightsName, + }; + + // Create the Logic App using the wizard-free method + const node: SlotTreeItem = await createLogicAppWithoutWizard( + createContext, + data.subscriptionId, + true // Skip notification since we're deploying next + ); + + // Mark as new app to skip overwrite confirmation and track webview deployment + (actionContext as any).isNewApp = true; + actionContext.telemetry.properties.deploymentSource = 'webview'; + actionContext.telemetry.properties.isNewLogicApp = 'true'; + + await deploy(actionContext, target, node); + + // Now deploy to the newly created Logic App + // Pass target (workspace Uri) and the node as functionAppId + // await deploy(actionContext, target, node); + } else { + // Deploy to existing Logic App - find the node first + const logicAppNode = (await ext.rgApi.appResourceTree.findTreeItem(data.logicAppId, actionContext)) as SlotTreeItem; + if (!logicAppNode) { + throw new Error(localize('noMatchingLogicApp', 'Failed to find Logic App "{0}".', data.logicAppId)); + } + + // Track webview deployment to existing app + actionContext.telemetry.properties.deploymentSource = 'webview'; + actionContext.telemetry.properties.isNewLogicApp = 'false'; + + // Pass target (workspace Uri) and logicAppNode as functionAppId + await deploy(actionContext, target, logicAppNode); + } + }, + extraHandlers: { + [ExtensionCommand.cancel_deploy]: async () => { + // Close the webview panel + const existingPanel = tryGetWebviewPanel(ext.webViewKey.deploy, localize('deployToAzure', 'Deploy to Azure')); + if (existingPanel) { + existingPanel.dispose(); + } + }, + [ExtensionCommand.getFilteredLocations]: async (message: any) => { + ext.outputChannel.appendLine(`[DEBUG] getFilteredLocations handler called with data: ${JSON.stringify(message.data)}`); + + const panel = tryGetWebviewPanel(ext.webViewKey.deploy, localize('deployToAzure', 'Deploy to Azure')); + if (!panel) { + ext.outputChannel.appendLine('[DEBUG] Panel not found'); + return; + } + + await callWithTelemetryAndErrorHandling('getFilteredLocations', async (actionContext: IActionContext) => { + try { + const subscriptionId = message.data?.subscriptionId; + ext.outputChannel.appendLine(`[DEBUG] Subscription ID: ${subscriptionId}`); + + // Get filtered locations matching the Logic Apps requirements + const wizardContext: any = { + ...actionContext, + subscriptionId: subscriptionId, + newSiteKind: AppKind.workflowapp, + newPlanSku: { tier: 'ElasticPremium' }, + }; + + ext.outputChannel.appendLine('[DEBUG] Calling getWebLocations...'); + const locations = await getWebLocations(wizardContext); + ext.outputChannel.appendLine(`[DEBUG] Got ${locations.length} locations`); + + const filteredLocations = locations.map((loc: any) => ({ + name: loc.name, + displayName: loc.displayName, + })); + + ext.outputChannel.appendLine(`[DEBUG] Sending response with ${filteredLocations.length} locations`); + panel.webview.postMessage({ + command: 'getFilteredLocationsResult', + locations: filteredLocations, + }); + } catch (error) { + ext.outputChannel.appendLine(`[DEBUG] Error: ${error}`); + panel.webview.postMessage({ + command: 'getFilteredLocationsResult', + error: (error as Error).message, + }); + } + }); + }, + }, + extraInitializeData: { + deploymentFolderPath: ext.deploymentFolderPath || target?.fsPath, + accessToken, + cloudHost, + }, + }); +} diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index 05ec470230e..521c0b0390f 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -27,7 +27,8 @@ import { createWorkflow } from './createWorkflow/createWorkflow'; import { createNewDataMapCmd, loadDataMapFileCmd } from './dataMapper/dataMapper'; import { deleteLogicApp } from './deleteLogicApp/deleteLogicApp'; import { deleteNode } from './deleteNode'; -import { deployProductionSlot, deploySlot } from './deploy/deploy'; +import { deploySlot } from './deploy/deploy'; +import { deployViaWebview } from './deploy/deployWebview'; import { connectToGitHub } from './deployments/connectToGitHub'; import { disconnectRepo } from './deployments/disconnectRepo'; import { redeployDeployment } from './deployments/redeployDeployment'; @@ -91,7 +92,7 @@ export function registerCommands(): void { registerCommand(extensionCommand.createWorkflow, createWorkflow); registerCommandWithTreeNodeUnwrapping(extensionCommand.createLogicApp, createLogicApp); registerCommandWithTreeNodeUnwrapping(extensionCommand.createLogicAppAdvanced, createLogicAppAdvanced); - registerSiteCommand(extensionCommand.deploy, unwrapTreeNodeCommandCallback(deployProductionSlot)); + registerCommand(extensionCommand.deploy, deployViaWebview); registerSiteCommand(extensionCommand.deploySlot, unwrapTreeNodeCommandCallback(deploySlot)); registerCommand(extensionCommand.generateDeploymentScripts, generateDeploymentScripts); registerSiteCommand(extensionCommand.redeploy, unwrapTreeNodeCommandCallback(redeployDeployment)); diff --git a/apps/vs-code-designer/src/app/tree/subscriptionTree/subscriptionTreeItem.ts b/apps/vs-code-designer/src/app/tree/subscriptionTree/subscriptionTreeItem.ts index 5bbbc2c6dd0..51a3320c4c1 100644 --- a/apps/vs-code-designer/src/app/tree/subscriptionTree/subscriptionTreeItem.ts +++ b/apps/vs-code-designer/src/app/tree/subscriptionTree/subscriptionTreeItem.ts @@ -39,6 +39,7 @@ import { AppInsightsCreateStep, AppInsightsListStep, AppKind, + AppServicePlanCreateStep, CustomLocationListStep, ParsedSite, SiteNameStep, @@ -58,6 +59,7 @@ import { uiUtils, VerifyProvidersStep, storageAccountNamingRules, + LocationListStep, } from '@microsoft/vscode-azext-azureutils'; import type { AzExtTreeItem, AzureWizardExecuteStep, AzureWizardPromptStep, IActionContext } from '@microsoft/vscode-azext-utils'; import { nonNullProp, parseError, AzureWizard } from '@microsoft/vscode-azext-utils'; @@ -108,6 +110,264 @@ export class SubscriptionTreeItem extends SubscriptionTreeItemBase { } ); } + + /** + * Creates a Logic App child without showing wizard prompts. + * All required information must be provided in the context. + */ + public static async createChildWithoutPrompts( + context: ICreateLogicAppContext & ILogicAppWizardContext, + subscription: SubscriptionTreeItem + ): Promise { + const version: FuncVersion = await getDefaultFuncVersion(context); + const language: string | undefined = getWorkspaceSettingFromAnyFolder(projectLanguageSetting); + + context.telemetry.properties.projectRuntime = version; + context.telemetry.properties.projectLanguage = language; + + // Merge subscription info and required properties + const wizardContext: ILogicAppWizardContext = Object.assign(context, subscription.subscription, { + newSiteKind: AppKind.workflowapp, + resourceGroupDeferLocationStep: true, + version, + language, + newSiteRuntime: workflowappRuntime, + runtimeFilter: getFunctionsWorkerRuntime(language), + ...(await createActivityContext()), + }); + + if (version === FuncVersion.v1) { + wizardContext.newSiteOS = WebsiteOS.windows; + } else { + // Default to Windows for Logic Apps + wizardContext.newSiteOS = WebsiteOS.windows; + } + + await setRegionsTask(wizardContext); + + // Get full location objects from Azure using the LocationListStep utility + // This ensures we get the complete location data with all metadata + await LocationListStep.setLocation(wizardContext, wizardContext.location); + + // Validate that the location was set properly + if (!wizardContext._location) { + throw new Error(localize('locationNotSet', 'Failed to resolve location "{0}".', wizardContext.location)); + } + + // Set location subset similar to createChild - this filters regions properly + const locations = await getWebLocations({ ...wizardContext, newPlanSku: wizardContext.newPlanSku ?? { tier: 'ElasticPremium' } }); + CustomLocationListStep.setLocationSubset(wizardContext, Promise.resolve(locations), 'microsoft.resources'); + + // Validate that the location is valid for Logic Apps + const providedLocation = wizardContext.location.toLowerCase().replace(/\s/g, ''); + const isValidLocation = locations.some((loc) => { + const locName = (loc || '').toLowerCase().replace(/\s/g, ''); + const locDisplayName = (loc || '').toLowerCase().replace(/\s/g, ''); + return locName === providedLocation || locDisplayName === providedLocation; + }); + + if (!isValidLocation) { + throw new Error(localize('invalidLocation', 'Location "{0}" is not valid for Logic Apps.', wizardContext.location)); + } + + // Set up resource group - ensure proper format + if (wizardContext.newResourceGroupName) { + // Creating new resource group - need to create resource group object with location + wizardContext.resourceGroup = { + name: wizardContext.newResourceGroupName, + location: wizardContext._location.name, + id: '', // Will be set after creation + managedBy: undefined, + properties: undefined, + tags: undefined, + type: 'Microsoft.Resources/resourceGroups', + }; + } else if (wizardContext.resourceGroup) { + // Existing resource group - ensure it has location set + if (!wizardContext.resourceGroup.location) { + wizardContext.resourceGroup.location = wizardContext._location.name; + } + } else { + throw new Error(localize('missingResourceGroup', 'Resource group is required to create a Logic App.')); + } + + // Set up execute steps only (no prompt steps) + const executeSteps: AzureWizardExecuteStep[] = []; + + const storageAccountCreateOptions: INewStorageAccountDefaults = { + kind: StorageAccountKind.Storage, + performance: StorageAccountPerformance.Standard, + replication: StorageAccountReplication.LRS, + }; + + // Set default Workflow Standard hosting plan properties + wizardContext.useHybrid = false; + wizardContext.suppressCreate = false; + wizardContext.planSkuFamilyFilter = /^WS$/i; // Workflow Standard + + // Handle App Service Plan - either use existing or create new + if (wizardContext.plan) { + // Using existing plan - fetch the full plan details + const client = await createWebSiteClient([context, subscription.subscription]); + const planId = wizardContext.plan.id; + const planIdParts = planId.split('/'); + const planResourceGroup = planIdParts[4]; + const planName = planIdParts[8]; + + const existingPlan = await client.appServicePlans.get(planResourceGroup, planName); + wizardContext.plan = existingPlan; + } else if (wizardContext.newPlanName) { + // Creating new plan - set up SKU based on user selection + const skuName = wizardContext.appServicePlanSku || 'WS1'; + wizardContext.newPlanSku = { + name: skuName, + tier: 'WorkflowStandard', + size: skuName, + family: 'WS', + }; + executeSteps.push(new AppServicePlanCreateStep()); + } else { + // No plan specified - create a new one with default SKU + const skuName = wizardContext.appServicePlanSku || 'WS1'; + wizardContext.newPlanSku = { + name: skuName, + tier: 'WorkflowStandard', + size: skuName, + family: 'WS', + }; + executeSteps.push(new AppServicePlanCreateStep()); + } + + // Handle storage account - fetch if existing, or prepare for creation + if (wizardContext.storageAccount) { + // Using existing storage account - fetch the full details + const storageId = wizardContext.storageAccount.id; + const storageIdParts = storageId.split('/'); + const storageResourceGroup = storageIdParts[4]; + const storageName = storageIdParts[8]; + + // The storage account will be used as-is from the ID + wizardContext.storageAccount = { + ...wizardContext.storageAccount, + name: storageName, + resourceGroup: storageResourceGroup, + }; + } + + // Add all necessary execute steps + // executeSteps.push(new ResourceGroupListStep()); + + // Handle storage account - either use existing or create new + if (wizardContext.storageAccount) { + // Using existing storage account - no additional step needed + // The storageAccount is already set in context + } else { + // Creating new storage account + executeSteps.push(new StorageAccountCreateStep(storageAccountCreateOptions)); + } + + // Add App Insights step only if requested + if (wizardContext.createAppInsights !== false) { + executeSteps.push(new AppInsightsCreateStep()); + } + + executeSteps.push(new VerifyProvidersStep([webProvider, storageProvider, insightsProvider])); + executeSteps.push(new LogicAppCreateStep()); + + wizardContext.activityTitle = localize('logicAppCreateActivityTitle', 'Creating Logic App "{0}"', wizardContext.newSiteName); + + context.telemetry.properties.os = wizardContext.newSiteOS; + context.telemetry.properties.runtime = wizardContext.newSiteRuntime; + + // Generate related names for storage and app insights if creating new ones + if (!wizardContext.storageAccount && !wizardContext.newStorageAccountName) { + const baseName: string | undefined = wizardContext.newSiteName; + const newName = await generateRelatedName(wizardContext, baseName); + if (!newName) { + throw new Error( + localize( + 'noUniqueName', + 'Failed to generate unique name for storage account. Use advanced creation to manually enter resource names.' + ) + ); + } + wizardContext.newStorageAccountName = newName; + } + + // Generate or use provided app insights name + if (wizardContext.createAppInsights !== false) { + if (!wizardContext.newAppInsightsName) { + const baseName: string | undefined = wizardContext.newSiteName; + const newName = await generateRelatedName(wizardContext, baseName); + if (!newName) { + throw new Error( + localize( + 'noUniqueName', + 'Failed to generate unique name for app insights. Use advanced creation to manually enter resource names.' + ) + ); + } + wizardContext.newAppInsightsName = newName; + } + // If newAppInsightsName is already set (from webview), use it as-is + } + + if (ext.deploymentFolderPath) { + let resourceGroupName: string | undefined; + + if (wizardContext.newResourceGroupName) { + resourceGroupName = wizardContext.newResourceGroupName; + } else if (wizardContext.resourceGroup && wizardContext.resourceGroup.name) { + resourceGroupName = wizardContext.resourceGroup.name; + } + + if (resourceGroupName) { + await verifyDeploymentResourceGroup(context, resourceGroupName, ext.deploymentFolderPath); + } + } + + // Execute steps without wizard prompts + const wizard: AzureWizard = new AzureWizard(wizardContext, { + promptSteps: [], // No prompt steps + executeSteps: executeSteps, + title: localize('functionAppCreatingTitle', 'Create new Logic App (Standard) in Azure'), + }); + + await wizard.execute(); + + const site = new ParsedSite(nonNullProp(wizardContext, 'site'), subscription.subscription); + const client: SiteClient = await site.createClient(context); + + if (!client.isLinux) { + try { + await enableFileLogging(client); + } catch (error) { + context.telemetry.properties.fileLoggingError = parseError(error).message; + } + } + + const resolved = new LogicAppResourceTree(subscription.subscription, nonNullProp(wizardContext, 'site')); + const logicAppMap = ext.subscriptionLogicAppMap.get(subscription.subscription.subscriptionId); + if (logicAppMap) { + logicAppMap.set(wizardContext.site.id.toLowerCase(), wizardContext.site); + } + await ext.rgApi.appResourceTree.refresh(context); + + const slotTreeItem = new SlotTreeItem(subscription, resolved, { + isHybridLogiApp: wizardContext.useHybrid, + hybridSite: wizardContext.hybridSite, + location: wizardContext.customLocation + ? wizardContext.customLocation.kubeEnvironment.location.replace(/[()]/g, '') + : wizardContext._location.name, + fileShare: wizardContext.fileShare, + connectedEnvironment: wizardContext.connectedEnvironment, + resourceGroupName: wizardContext.resourceGroup.name, + sqlConnectionString: wizardContext.sqlConnectionString, + }); + + return slotTreeItem; + } + public static async createChild(context: ICreateLogicAppContext, subscription: SubscriptionTreeItem): Promise { const version: FuncVersion = await getDefaultFuncVersion(context); const language: string | undefined = getWorkspaceSettingFromAnyFolder(projectLanguageSetting); diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index fc052a4b195..5dc7bdf2546 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -97,6 +97,7 @@ export namespace ext { createWorkspaceFromPackage: 'createWorkspaceFromPackage', createLogicApp: 'createLogicApp', createWorkspaceStructure: 'createWorkspaceStructure', + deploy: 'deploy', } as const; export type webViewKey = keyof typeof webViewKey; @@ -110,6 +111,7 @@ export namespace ext { [webViewKey.createWorkspaceStructure]: {}, [webViewKey.createLogicApp]: {}, [webViewKey.overview]: {}, + [webViewKey.deploy]: {}, }; export const log = (text: string) => { diff --git a/apps/vs-code-react/src/app/deploy/deploy.tsx b/apps/vs-code-react/src/app/deploy/deploy.tsx new file mode 100644 index 00000000000..de99960ca9b --- /dev/null +++ b/apps/vs-code-react/src/app/deploy/deploy.tsx @@ -0,0 +1,853 @@ +import type { RootState } from '../../state/store'; +import { + setSelectedSubscription, + setSelectedLogicApp, + setIsCreatingNew, + setNewLogicAppName, + setSelectedResourceGroup, + setNewResourceGroupName, + setSelectedLocation, + setResourceGroups, + setLocations, + setSelectedAppServicePlan, + setNewAppServicePlanName, + setSelectedAppServicePlanSku, + setSelectedStorageAccount, + setNewStorageAccountName, + setCreateAppInsights, + setNewAppInsightsName, + setDeploying, +} from '../../state/deploySlice'; +import { useSelector, useDispatch } from 'react-redux'; +import { useDeployStyles } from './deployStyles'; +import { Button, Dropdown, Option, Spinner, Text, Input, Label, Checkbox } from '@fluentui/react-components'; +import { VSCodeContext } from '../../webviewCommunication'; +import { useContext, useMemo, useEffect, useState } from 'react'; +import { ApiService } from '../../run-service/export'; +import type { ISubscription } from '../../run-service'; +import { QueryKeys } from '../../run-service'; +import { useQuery } from '@tanstack/react-query'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; +import { useIntlMessages, deployMessages } from '../../intl'; + +export const DeployApp: React.FC = () => { + const vscode = useContext(VSCodeContext); + const dispatch = useDispatch(); + const styles = useDeployStyles(); + const intlText = useIntlMessages(deployMessages); + + const [isCheckingStorageName, setIsCheckingStorageName] = useState(false); + const [storageNameUnavailable, setStorageNameUnavailable] = useState(false); + const [storageNameMessage, setStorageNameMessage] = useState(''); + + const deployState = useSelector((state: RootState) => state.deploy); + const workflowState = useSelector((state: RootState) => state.workflow); + const { baseUrl, accessToken, cloudHost } = workflowState; + + const { + selectedSubscription, + selectedLogicApp, + selectedLogicAppName, + selectedResourceGroup, + isCreatingNew, + newLogicAppName, + newResourceGroupName, + isCreatingNewResourceGroup, + selectedLocation, + selectedAppServicePlan, + newAppServicePlanName, + isCreatingNewAppServicePlan, + selectedAppServicePlanSku, + selectedStorageAccount, + newStorageAccountName, + isCreatingNewStorageAccount, + createAppInsights, + newAppInsightsName, + appInsightsNameManuallyChanged, + isDeploying, + deploymentStatus, + deploymentMessage, + error, + } = deployState; + + const apiService = useMemo(() => { + return new ApiService({ + baseUrl, + accessToken, + cloudHost, + vscodeContext: vscode, + }); + }, [accessToken, baseUrl, cloudHost, vscode]); + + const { data: subscriptionsList, isLoading: isSubscriptionsLoading } = useQuery( + [QueryKeys.subscriptionData], + () => apiService.getSubscriptions(), + { + refetchOnWindowFocus: false, + enabled: accessToken !== undefined, + retry: 4, + } + ); + + const { + data: logicAppsList, + isLoading: isLogicAppsLoading, + refetch: refetchLogicApps, + } = useQuery( + [QueryKeys.resourceGroupsData, { subscriptionId: selectedSubscription }], + () => apiService.getLogicApps(selectedSubscription), + { + refetchOnWindowFocus: false, + enabled: !!selectedSubscription, + retry: 4, + } + ); + + const { + data: resourceGroupsList, + isLoading: isResourceGroupsLoading, + refetch: refetchResourceGroups, + } = useQuery>( + [QueryKeys.resourceGroupsData, 'rgs', { subscriptionId: selectedSubscription }], + () => apiService.getResourceGroups(selectedSubscription), + { + refetchOnWindowFocus: false, + enabled: !!selectedSubscription && isCreatingNew, + retry: 4, + } + ); + + const { + data: locationsList, + isLoading: isLocationsLoading, + refetch: refetchLocations, + } = useQuery>( + [QueryKeys.resourceGroupsData, 'locations', { subscriptionId: selectedSubscription }], + () => apiService.getLocations(selectedSubscription), + { + refetchOnWindowFocus: false, + enabled: !!selectedSubscription && isCreatingNew, + retry: 4, + } + ); + + // Fetch all app service plans once for the subscription + const { data: allAppServicePlans = [], isLoading: isAppServicePlansLoading } = useQuery( + [QueryKeys.resourceGroupsData, 'appServicePlans', { subscriptionId: selectedSubscription }], + () => apiService.getAppServicePlans(selectedSubscription), + { + refetchOnWindowFocus: false, + enabled: !!selectedSubscription && isCreatingNew, + retry: 4, + } + ); + + // Fetch all storage accounts once for the subscription + const { data: allStorageAccounts = [], isLoading: isStorageAccountsLoading } = useQuery< + Array<{ id: string; name: string; location: string }> + >( + [QueryKeys.resourceGroupsData, 'storageAccounts', { subscriptionId: selectedSubscription }], + () => apiService.getStorageAccounts(selectedSubscription), + { + refetchOnWindowFocus: false, + enabled: !!selectedSubscription && isCreatingNew, + retry: 4, + } + ); + + // Filter app service plans by selected location and WorkflowStandard tier + const appServicePlansList = useMemo( + () => allAppServicePlans.filter((plan) => plan.location === selectedLocation && plan.sku?.tier === 'WorkflowStandard'), + [allAppServicePlans, selectedLocation] + ); + + // Filter storage accounts by selected location + const storageAccountsList = useMemo( + () => allStorageAccounts.filter((storage) => storage.location === selectedLocation), + [allStorageAccounts, selectedLocation] + ); + + useEffect(() => { + if (selectedSubscription) { + refetchLogicApps(); + } + }, [selectedSubscription, refetchLogicApps]); + + useEffect(() => { + if (selectedSubscription && isCreatingNew) { + refetchResourceGroups(); + refetchLocations(); + } + }, [selectedSubscription, isCreatingNew, refetchResourceGroups, refetchLocations]); + + useEffect(() => { + if (resourceGroupsList) { + dispatch(setResourceGroups(resourceGroupsList)); + } + }, [resourceGroupsList, dispatch]); + + useEffect(() => { + if (locationsList) { + dispatch(setLocations(locationsList)); + } + }, [locationsList, dispatch]); + + // Generate resource names based on Logic App name + useEffect(() => { + if (newLogicAppName && isCreatingNew) { + // Generate a simple GUID-like suffix + const generateGuid = () => { + return Math.random().toString(36).substring(2, 10); + }; + + const guid = generateGuid(); + + // App Service Plan: ASP-{logicappname}-{guid} + if (isCreatingNewAppServicePlan) { + const aspName = `ASP-${newLogicAppName}-${guid}`; + dispatch(setNewAppServicePlanName(aspName)); + } + + // Storage Account: lowercase alphanumeric only, max 24 chars + if (isCreatingNewStorageAccount) { + const storageName = `${newLogicAppName.toLowerCase().replace(/[^a-z0-9]/g, '')}${guid}`.substring(0, 24); + dispatch(setNewStorageAccountName(storageName)); + } + + // App Insights: same as Logic App name (only update if not manually changed) + if (createAppInsights && !appInsightsNameManuallyChanged) { + dispatch(setNewAppInsightsName(newLogicAppName)); + } + } + }, [ + newLogicAppName, + isCreatingNew, + isCreatingNewAppServicePlan, + isCreatingNewStorageAccount, + createAppInsights, + appInsightsNameManuallyChanged, + dispatch, + ]); + + // Check if logic app name already exists + const logicAppNameExists = useMemo(() => { + if (!newLogicAppName || !isCreatingNew || !logicAppsList) { + return false; + } + return logicAppsList.some((app) => app.name.toLowerCase() === newLogicAppName.toLowerCase()); + }, [newLogicAppName, isCreatingNew, logicAppsList]); + + // Check if app service plan name already exists + const appServicePlanNameExists = useMemo(() => { + if (!newAppServicePlanName || !isCreatingNew || !isCreatingNewAppServicePlan || !allAppServicePlans) { + return false; + } + return allAppServicePlans.some((plan) => plan.name.toLowerCase() === newAppServicePlanName.toLowerCase()); + }, [newAppServicePlanName, isCreatingNew, isCreatingNewAppServicePlan, allAppServicePlans]); + + // Validate Logic App name (1-43 chars, alphanumeric and hyphens only) + // Note: Despite Azure docs listing more characters, Logic Apps Standard only accepts alphanumerics and hyphens + const logicAppNameError = useMemo(() => { + if (!newLogicAppName || !isCreatingNew) { + return ''; + } + if (newLogicAppName.length < 1 || newLogicAppName.length > 43) { + return intlText.LOGIC_APP_NAME_LENGTH_ERROR; + } + if (!/^[a-zA-Z0-9-]+$/.test(newLogicAppName)) { + return intlText.LOGIC_APP_NAME_CHARS_ERROR; + } + return ''; + }, [newLogicAppName, isCreatingNew, intlText]); + + // Validate App Service Plan name (1-60 chars, alphanumeric/hyphens, cannot start/end with hyphen) + const appServicePlanNameError = useMemo(() => { + if (!newAppServicePlanName || !isCreatingNew || !isCreatingNewAppServicePlan) { + return ''; + } + if (newAppServicePlanName.length < 1 || newAppServicePlanName.length > 60) { + return intlText.APP_SERVICE_PLAN_NAME_LENGTH_ERROR; + } + if (newAppServicePlanName.startsWith('-') || newAppServicePlanName.endsWith('-')) { + return intlText.APP_SERVICE_PLAN_NAME_HYPHEN_ERROR; + } + if (!/^[a-zA-Z0-9-]+$/.test(newAppServicePlanName)) { + return intlText.APP_SERVICE_PLAN_NAME_CHARS_ERROR; + } + return ''; + }, [newAppServicePlanName, isCreatingNew, isCreatingNewAppServicePlan, intlText]); + + // Validate Storage Account name (3-24 chars, lowercase letters/numbers only) + const storageAccountNameError = useMemo(() => { + if (!newStorageAccountName || !isCreatingNew || !isCreatingNewStorageAccount) { + return ''; + } + if (newStorageAccountName.length < 3 || newStorageAccountName.length > 24) { + return intlText.STORAGE_ACCOUNT_NAME_LENGTH_ERROR; + } + if (!/^[a-z0-9]+$/.test(newStorageAccountName)) { + return intlText.STORAGE_ACCOUNT_NAME_CHARS_ERROR; + } + return ''; + }, [newStorageAccountName, isCreatingNew, isCreatingNewStorageAccount, intlText]); + + // Check storage account name availability (debounced) + useEffect(() => { + if (!newStorageAccountName || !isCreatingNew || !isCreatingNewStorageAccount || !selectedSubscription || storageAccountNameError) { + setStorageNameUnavailable(false); + setStorageNameMessage(''); + return; + } + + setIsCheckingStorageName(true); + const timeoutId = setTimeout(async () => { + try { + const result = await apiService.checkStorageAccountNameAvailability(selectedSubscription, newStorageAccountName); + setStorageNameUnavailable(!result.available); + setStorageNameMessage(result.message || ''); + } catch (error) { + console.error('Error checking storage account name availability:', error); + } finally { + setIsCheckingStorageName(false); + } + }, 500); // 500ms debounce + + return () => clearTimeout(timeoutId); + }, [newStorageAccountName, isCreatingNew, isCreatingNewStorageAccount, selectedSubscription, storageAccountNameError, apiService]); + + // Check storage account name availability (debounced) + useEffect(() => { + if (!newStorageAccountName || !isCreatingNew || !isCreatingNewStorageAccount || !selectedSubscription || storageAccountNameError) { + setStorageNameUnavailable(false); + setStorageNameMessage(''); + return; + } + + setIsCheckingStorageName(true); + const timeoutId = setTimeout(async () => { + try { + const result = await apiService.checkStorageAccountNameAvailability(selectedSubscription, newStorageAccountName); + setStorageNameUnavailable(!result.available); + setStorageNameMessage(result.message || ''); + } catch (error) { + console.error('Error checking storage account name availability:', error); + } finally { + setIsCheckingStorageName(false); + } + }, 500); // 500ms debounce + + return () => clearTimeout(timeoutId); + }, [newStorageAccountName, isCreatingNew, isCreatingNewStorageAccount, selectedSubscription, storageAccountNameError, apiService]); + + const handleSubscriptionChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedSubscription(data.optionValue)); + } + }; + + const handleLogicAppChange = (_event: any, data: any) => { + if (data.optionValue) { + if (data.optionValue === '__CREATE_NEW__') { + dispatch(setIsCreatingNew(true)); + } else { + const selectedApp = logicAppsList?.find((app) => app.id === data.optionValue); + if (selectedApp) { + dispatch( + setSelectedLogicApp({ + id: selectedApp.id, + name: selectedApp.name, + resourceGroup: selectedApp.resourceGroup, + }) + ); + } + } + } + }; + + const handleNewLogicAppNameChange = (_event: any, data: any) => { + dispatch(setNewLogicAppName(data.value)); + }; + + const handleResourceGroupChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedResourceGroup(data.optionValue)); + } + }; + + const handleNewResourceGroupNameChange = (_event: any, data: any) => { + dispatch(setNewResourceGroupName(data.value)); + }; + + const handleLocationChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedLocation(data.optionValue)); + } + }; + + const handleAppServicePlanChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedAppServicePlan(data.optionValue)); + } + }; + + const handleNewAppServicePlanNameChange = (_event: any, data: any) => { + dispatch(setNewAppServicePlanName(data.value)); + }; + + const handleAppServicePlanSkuChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedAppServicePlanSku(data.optionValue)); + } + }; + + const handleStorageAccountChange = (_event: any, data: any) => { + if (data.optionValue) { + dispatch(setSelectedStorageAccount(data.optionValue)); + } + }; + + const handleNewStorageAccountNameChange = (_event: any, data: any) => { + dispatch(setNewStorageAccountName(data.value)); + }; + + const handleCreateAppInsightsChange = (_event: any, data: any) => { + dispatch(setCreateAppInsights(data.checked)); + }; + + const handleDeploy = () => { + if (isCreatingNew) { + // Creating a new Logic App + if (!newLogicAppName || !selectedLocation) { + return; + } + + const finalResourceGroup = isCreatingNewResourceGroup ? newResourceGroupName : selectedResourceGroup; + if (!finalResourceGroup) { + return; + } + + const finalAppServicePlan = isCreatingNewAppServicePlan ? newAppServicePlanName : selectedAppServicePlan; + if (!finalAppServicePlan && !isCreatingNewAppServicePlan) { + return; + } + + const finalStorageAccount = isCreatingNewStorageAccount ? newStorageAccountName : selectedStorageAccount; + if (!finalStorageAccount && !isCreatingNewStorageAccount) { + return; + } + + dispatch(setDeploying(true)); + + vscode.postMessage({ + command: ExtensionCommand.deploy, + data: { + subscriptionId: selectedSubscription, + createNew: true, + newLogicAppName, + resourceGroup: finalResourceGroup, + isCreatingNewResourceGroup, + location: selectedLocation, + appServicePlan: finalAppServicePlan, + isCreatingNewAppServicePlan, + appServicePlanSku: selectedAppServicePlanSku, + storageAccount: finalStorageAccount, + isCreatingNewStorageAccount, + createAppInsights, + appInsightsName: createAppInsights ? newAppInsightsName : undefined, + }, + }); + } else { + // Deploying to existing Logic App + if (!selectedLogicApp) { + return; + } + + dispatch(setDeploying(true)); + + vscode.postMessage({ + command: ExtensionCommand.deploy, + data: { + subscriptionId: selectedSubscription, + logicAppId: selectedLogicApp, + logicAppName: selectedLogicAppName, + resourceGroup: selectedResourceGroup, + }, + }); + } + }; + + const handleCancel = () => { + vscode.postMessage({ + command: ExtensionCommand.cancel_deploy, + }); + }; + + const canDeploy = isCreatingNew + ? selectedSubscription && + newLogicAppName && + !logicAppNameExists && + !logicAppNameError && + !appServicePlanNameExists && + !appServicePlanNameError && + !storageAccountNameError && + !storageNameUnavailable && + !isCheckingStorageName && + (isCreatingNewResourceGroup ? newResourceGroupName : selectedResourceGroup) && + selectedLocation && + (isCreatingNewStorageAccount ? newStorageAccountName : selectedStorageAccount) && + !isDeploying + : selectedSubscription && selectedLogicApp && !isDeploying; + + return ( +
+ {intlText.DEPLOY_TO_AZURE} + +
+
+ {intlText.SELECT_SUBSCRIPTION} + {isSubscriptionsLoading ? ( +
+ + {intlText.LOADING_SUBSCRIPTIONS} +
+ ) : ( + s.subscriptionId === selectedSubscription)?.subscriptionName || ''} + onOptionSelect={handleSubscriptionChange} + disabled={isDeploying} + > + {subscriptionsList?.map((subscription) => ( + + ))} + + )} +
+ + {selectedSubscription && ( +
+ {intlText.SELECT_LOGIC_APP} + {isLogicAppsLoading ? ( +
+ + {intlText.LOADING_LOGIC_APPS} +
+ ) : ( + + + {logicAppsList?.map((app) => ( + + ))} + + )} +
+ )} + + {selectedSubscription && isCreatingNew && ( + <> +
+ + + {logicAppNameExists && ( + + {intlText.LOGIC_APP_NAME_EXISTS} + + )} + {logicAppNameError && ( + {logicAppNameError} + )} +
+ +
+ {intlText.RESOURCE_GROUP} + {isResourceGroupsLoading ? ( +
+ + {intlText.LOADING_RESOURCE_GROUPS} +
+ ) : ( + rg.name === selectedResourceGroup)?.name || '' + } + onOptionSelect={handleResourceGroupChange} + disabled={isDeploying} + > + + {resourceGroupsList?.map((rg) => ( + + ))} + + )} +
+ + {isCreatingNewResourceGroup && ( +
+ + +
+ )} + +
+ {intlText.LOCATION} + {isLocationsLoading ? ( +
+ + {intlText.LOADING_LOCATIONS} +
+ ) : ( + loc.name === selectedLocation)?.displayName || ''} + onOptionSelect={handleLocationChange} + disabled={isDeploying} + > + {locationsList?.map((loc) => ( + + ))} + + )} +
+ + {selectedLocation && ( +
+ {intlText.APP_SERVICE_PLAN} + {isAppServicePlansLoading ? ( +
+ + {intlText.LOADING_APP_SERVICE_PLANS} +
+ ) : ( + plan.id === selectedAppServicePlan)?.name || '' + } + onOptionSelect={handleAppServicePlanChange} + disabled={isDeploying} + > + + {appServicePlansList?.map((plan) => ( + + ))} + + )} +
+ )} + + {isCreatingNewAppServicePlan && ( + <> +
+ + + {appServicePlanNameExists && ( + + {intlText.APP_SERVICE_PLAN_NAME_EXISTS} + + )} + {appServicePlanNameError && ( + + {appServicePlanNameError} + + )} +
+ +
+ {intlText.APP_SERVICE_PLAN_SKU} + + + + + +
+ + )} + + {selectedLocation && ( +
+ {intlText.STORAGE_ACCOUNT} + {isStorageAccountsLoading ? ( +
+ + {intlText.LOADING_STORAGE_ACCOUNTS} +
+ ) : ( + storage.id === selectedStorageAccount)?.name || '' + } + onOptionSelect={handleStorageAccountChange} + disabled={isDeploying} + > + + {storageAccountsList?.map((storage) => ( + + ))} + + )} +
+ )} + + {isCreatingNewStorageAccount && ( +
+ + + {isCheckingStorageName && ( + + {intlText.CHECKING_AVAILABILITY} + + )} + {storageAccountNameError && ( + + {storageAccountNameError} + + )} + {!storageAccountNameError && storageNameUnavailable && ( + + {storageNameMessage || intlText.STORAGE_ACCOUNT_NAME_TAKEN} + + )} + {!storageAccountNameError && + !storageNameUnavailable && + !isCheckingStorageName && + newStorageAccountName && + newStorageAccountName.length >= 3 && ( + + {intlText.STORAGE_ACCOUNT_NAME_AVAILABLE} + + )} +
+ )} + +
+ +
+ + {createAppInsights && ( +
+ + dispatch(setNewAppInsightsName(data.value))} + disabled={isDeploying} + /> +
+ )} + + )} + + {error && {error}} + + {deploymentStatus === 'success' && ( + {deploymentMessage || intlText.DEPLOYMENT_SUCCESS} + )} + + {deploymentStatus === 'failed' && {deploymentMessage || intlText.DEPLOYMENT_FAILED}} +
+ +
+ {isDeploying ? ( +
+ + {intlText.DEPLOYING} +
+ ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/apps/vs-code-react/src/app/deploy/deployStyles.ts b/apps/vs-code-react/src/app/deploy/deployStyles.ts new file mode 100644 index 00000000000..ba0b5b32b77 --- /dev/null +++ b/apps/vs-code-react/src/app/deploy/deployStyles.ts @@ -0,0 +1,55 @@ +import { makeStyles, shorthands, tokens } from '@fluentui/react-components'; + +export const useDeployStyles = makeStyles({ + deployContainer: { + display: 'flex', + flexDirection: 'column', + height: '100%', + ...shorthands.padding(tokens.spacingVerticalL, tokens.spacingHorizontalXXL), + }, + deployTitle: { + fontSize: tokens.fontSizeBase600, + fontWeight: tokens.fontWeightSemibold, + marginBottom: tokens.spacingVerticalXXL, + display: 'block', + }, + deployContent: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap(tokens.spacingVerticalL), + flex: 1, + }, + deploySection: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap(tokens.spacingVerticalM), + }, + sectionTitle: { + fontSize: tokens.fontSizeBase400, + fontWeight: tokens.fontWeightSemibold, + }, + dropdown: { + minWidth: '400px', + }, + deployActions: { + display: 'flex', + ...shorthands.gap(tokens.spacingHorizontalM), + marginTop: tokens.spacingVerticalXXL, + }, + deployButton: { + minWidth: '120px', + }, + errorMessage: { + color: tokens.colorPaletteRedForeground1, + marginTop: tokens.spacingVerticalM, + }, + successMessage: { + color: tokens.colorPaletteGreenForeground1, + marginTop: tokens.spacingVerticalM, + }, + loadingContainer: { + display: 'flex', + alignItems: 'center', + ...shorthands.gap(tokens.spacingHorizontalM), + }, +}); diff --git a/apps/vs-code-react/src/intl/index.ts b/apps/vs-code-react/src/intl/index.ts index 3bec1b45203..b9763a88ff8 100644 --- a/apps/vs-code-react/src/intl/index.ts +++ b/apps/vs-code-react/src/intl/index.ts @@ -30,4 +30,5 @@ export { designerMessages, overviewMessages, chatMessages, + deployMessages, } from './messages'; diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index e74ee2d9e2e..c78b54e8386 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -1149,3 +1149,276 @@ export const chatMessages = defineMessages({ description: 'Debug project error message', }, }); + +export const deployMessages = defineMessages({ + DEPLOY_TO_AZURE: { + defaultMessage: 'Deploy to Azure', + id: 'qyCdsU', + description: 'Deploy to Azure page title', + }, + SELECT_SUBSCRIPTION: { + defaultMessage: 'Select Subscription', + id: 'oC7SJf', + description: 'Select subscription section title', + }, + SELECT_SUBSCRIPTION_PLACEHOLDER: { + defaultMessage: 'Select a subscription', + id: 'gVJJb9', + description: 'Select subscription dropdown placeholder', + }, + LOADING_SUBSCRIPTIONS: { + defaultMessage: 'Loading subscriptions...', + id: 'qmJ4fl', + description: 'Loading subscriptions message', + }, + SELECT_LOGIC_APP: { + defaultMessage: 'Select Logic App (Standard)', + id: 'WT3rmZ', + description: 'Select logic app section title', + }, + SELECT_LOGIC_APP_PLACEHOLDER: { + defaultMessage: 'Select a Logic App or create new', + id: 'Lyal9O', + description: 'Select logic app dropdown placeholder', + }, + CREATE_NEW_LOGIC_APP: { + defaultMessage: 'Create new Logic App...', + id: '0L/IsP', + description: 'Create new logic app option', + }, + LOADING_LOGIC_APPS: { + defaultMessage: 'Loading Logic Apps...', + id: 'X1Edk0', + description: 'Loading logic apps message', + }, + LOGIC_APP_NAME: { + defaultMessage: 'Logic App Name', + id: 'JS7xBY', + description: 'Logic app name field label', + }, + LOGIC_APP_NAME_PLACEHOLDER: { + defaultMessage: 'Enter Logic App name', + id: 'Ec6eYa', + description: 'Logic app name field placeholder', + }, + LOGIC_APP_NAME_EXISTS: { + defaultMessage: 'A Logic App with this name already exists in the subscription', + id: 'XhIjby', + description: 'Logic app name already exists error', + }, + LOGIC_APP_NAME_LENGTH_ERROR: { + defaultMessage: 'Logic App name must be 1-43 characters', + id: '86EIs+', + description: 'Logic app name length validation error', + }, + LOGIC_APP_NAME_CHARS_ERROR: { + defaultMessage: 'Logic App name can only contain letters, numbers, and hyphens', + id: 'BSrw3e', + description: 'Logic app name characters validation error', + }, + RESOURCE_GROUP: { + defaultMessage: 'Resource Group', + id: 'b0O0kA', + description: 'Resource group section title', + }, + RESOURCE_GROUP_PLACEHOLDER: { + defaultMessage: 'Select a resource group or create new', + id: 'lFeQ3D', + description: 'Resource group dropdown placeholder', + }, + CREATE_NEW_RESOURCE_GROUP: { + defaultMessage: 'Create new resource group...', + id: 'gsVmMc', + description: 'Create new resource group option', + }, + LOADING_RESOURCE_GROUPS: { + defaultMessage: 'Loading resource groups...', + id: 'acZfqv', + description: 'Loading resource groups message', + }, + NEW_RESOURCE_GROUP_NAME: { + defaultMessage: 'New Resource Group Name', + id: 'MFakiI', + description: 'New resource group name field label', + }, + NEW_RESOURCE_GROUP_NAME_PLACEHOLDER: { + defaultMessage: 'Enter resource group name', + id: '/eXdxq', + description: 'New resource group name field placeholder', + }, + RESOURCE_GROUP_NAME_PLACEHOLDER: { + defaultMessage: 'Enter resource group name', + id: 'rJ0jxe', + description: 'Resource group name field placeholder', + }, + LOCATION: { + defaultMessage: 'Location', + id: '4w3/SG', + description: 'Location section title', + }, + LOCATION_PLACEHOLDER: { + defaultMessage: 'Select a location', + id: 'a6tmNg', + description: 'Location dropdown placeholder', + }, + LOADING_LOCATIONS: { + defaultMessage: 'Loading locations...', + id: 'reaWnc', + description: 'Loading locations message', + }, + APP_SERVICE_PLAN: { + defaultMessage: 'App Service Plan', + id: '/xX/S0', + description: 'App service plan section title', + }, + APP_SERVICE_PLAN_PLACEHOLDER: { + defaultMessage: 'Select an app service plan or create new', + id: 'rDqeFZ', + description: 'App service plan dropdown placeholder', + }, + CREATE_NEW_APP_SERVICE_PLAN: { + defaultMessage: 'Create new app service plan...', + id: 'R/Mtnd', + description: 'Create new app service plan option', + }, + LOADING_APP_SERVICE_PLANS: { + defaultMessage: 'Loading app service plans...', + id: 'xOME2s', + description: 'Loading app service plans message', + }, + NEW_APP_SERVICE_PLAN_NAME: { + defaultMessage: 'New App Service Plan Name', + id: 'X9i5z8', + description: 'New app service plan name field label', + }, + APP_SERVICE_PLAN_NAME_PLACEHOLDER: { + defaultMessage: 'Enter app service plan name', + id: '/1MeIz', + description: 'App service plan name input placeholder', + }, + APP_SERVICE_PLAN_NAME_EXISTS: { + defaultMessage: 'An App Service Plan with this name already exists in the subscription', + id: 'nYMxSN', + description: 'App service plan name already exists error', + }, + APP_SERVICE_PLAN_NAME_LENGTH_ERROR: { + defaultMessage: 'App Service Plan name must be 1-60 characters', + id: 'GASpMu', + description: 'App service plan name length validation error', + }, + APP_SERVICE_PLAN_NAME_HYPHEN_ERROR: { + defaultMessage: 'App Service Plan name cannot start or end with a hyphen', + id: 'pb0mAB', + description: 'App service plan name hyphen validation error', + }, + APP_SERVICE_PLAN_NAME_CHARS_ERROR: { + defaultMessage: 'App Service Plan name can only contain letters, numbers, and hyphens', + id: '6fUN/I', + description: 'App service plan name characters validation error', + }, + APP_SERVICE_PLAN_SKU: { + defaultMessage: 'App Service Plan SKU', + id: 'mKrP3D', + description: 'App service plan SKU section title', + }, + SELECT_SKU_PLACEHOLDER: { + defaultMessage: 'Select SKU', + id: 'K/eK9y', + description: 'Select SKU dropdown placeholder', + }, + STORAGE_ACCOUNT: { + defaultMessage: 'Storage Account', + id: 'JNr5XL', + description: 'Storage account section title', + }, + STORAGE_ACCOUNT_PLACEHOLDER: { + defaultMessage: 'Select a storage account or create new', + id: 'Q3v+MD', + description: 'Storage account dropdown placeholder', + }, + CREATE_NEW_STORAGE_ACCOUNT: { + defaultMessage: 'Create new storage account...', + id: 'XCw/Zq', + description: 'Create new storage account option', + }, + LOADING_STORAGE_ACCOUNTS: { + defaultMessage: 'Loading storage accounts...', + id: 'iQVHMv', + description: 'Loading storage accounts message', + }, + NEW_STORAGE_ACCOUNT_NAME: { + defaultMessage: 'New Storage Account Name', + id: 'nM6NU5', + description: 'New storage account name field label', + }, + NEW_STORAGE_ACCOUNT_NAME_PLACEHOLDER: { + defaultMessage: 'Enter storage account name (3-24 lowercase letters/numbers)', + id: 'Y9O3Qo', + description: 'New storage account name field placeholder', + }, + STORAGE_ACCOUNT_NAME_PLACEHOLDER: { + defaultMessage: 'Enter storage account name (3-24 lowercase letters/numbers)', + id: 'CSoIzV', + description: 'Storage account name field placeholder', + }, + CHECKING_AVAILABILITY: { + defaultMessage: 'Checking availability...', + id: 'W0L2Pw', + description: 'Checking storage account name availability message', + }, + STORAGE_ACCOUNT_NAME_LENGTH_ERROR: { + defaultMessage: 'Storage account name must be 3-24 characters', + id: 'ZrQ3wQ', + description: 'Storage account name length validation error', + }, + STORAGE_ACCOUNT_NAME_CHARS_ERROR: { + defaultMessage: 'Storage account name can only contain lowercase letters and numbers', + id: '6B9lt7', + description: 'Storage account name characters validation error', + }, + STORAGE_ACCOUNT_NAME_TAKEN: { + defaultMessage: 'This storage account name is already taken', + id: 'qPxlLl', + description: 'Storage account name already taken error', + }, + STORAGE_ACCOUNT_NAME_AVAILABLE: { + defaultMessage: '✓ Storage account name is available', + id: 'OBtZng', + description: 'Storage account name available success message', + }, + CREATE_APP_INSIGHTS: { + defaultMessage: 'Create Application Insights', + id: '5YtO/R', + description: 'Create application insights checkbox label', + }, + APP_INSIGHTS_NAME: { + defaultMessage: 'Application Insights Name', + id: 'cHEUmj', + description: 'Application insights name field label', + }, + DEPLOY_BUTTON: { + defaultMessage: 'Deploy', + id: 'A90OoF', + description: 'Deploy button label', + }, + CANCEL_BUTTON: { + defaultMessage: 'Cancel', + id: '0GT0SI', + description: 'Cancel button label', + }, + DEPLOYING: { + defaultMessage: 'Deploying...', + id: 'RZt22h', + description: 'Deploying message', + }, + DEPLOYMENT_SUCCESS: { + defaultMessage: 'Deployment completed successfully!', + id: '6FuXLA', + description: 'Deployment success message', + }, + DEPLOYMENT_FAILED: { + defaultMessage: 'Deployment failed. Please check the output.', + id: '70rcZ3', + description: 'Deployment failed message', + }, +}); diff --git a/apps/vs-code-react/src/router/index.tsx b/apps/vs-code-react/src/router/index.tsx index 9d47966c68f..d002b59a388 100644 --- a/apps/vs-code-react/src/router/index.tsx +++ b/apps/vs-code-react/src/router/index.tsx @@ -16,6 +16,7 @@ import { CreateLogicApp, CreateWorkspaceStructure, } from '../app/createWorkspace/createWorkspace'; +import { DeployApp } from '../app/deploy/deploy'; import { RouteName } from '../run-service'; import { StateWrapper } from '../stateWrapper'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -41,6 +42,7 @@ export const Router: React.FC = () => { } /> } /> } /> + } /> ); diff --git a/apps/vs-code-react/src/run-service/export/index.ts b/apps/vs-code-react/src/run-service/export/index.ts index fc636aa5d5a..2db0b0d48cc 100644 --- a/apps/vs-code-react/src/run-service/export/index.ts +++ b/apps/vs-code-react/src/run-service/export/index.ts @@ -71,6 +71,14 @@ export class ApiService implements IApiService { subscriptions: [selectedSubscription], }; } + case ResourceType.logicApps: { + const selectedSubscription = properties?.selectedSubscription; + return { + query: + "resources | where type =~ 'Microsoft.Web/sites' and kind contains 'workflowapp' | extend siteName=name | project id, siteName, location, subscriptionId, resourceGroup, kind | sort by (tolower(tostring(siteName))) asc", + subscriptions: [selectedSubscription], + }; + } case ResourceType.workflows: { const subscriptionId = properties?.selectedSubscription; const selectedIse = properties?.selectedIse; @@ -311,7 +319,7 @@ export class ApiService implements IApiService { return exportResponse; } - async getResourceGroups(selectedSubscription: string): Promise { + async getResourceGroups(selectedSubscription: string): Promise> { const headers = this.getAccessTokenHeaders(); const payload = this.getPayload(ResourceType.resourcegroups, { selectedSubscription: selectedSubscription }); const response = await fetch(this.graphApiUri, { headers, method: 'POST', body: JSON.stringify(payload) }); @@ -325,7 +333,133 @@ export class ApiService implements IApiService { const resourceGroupsResponse: any = await response.json(); const { data: resourceGroups } = resourceGroupsResponse; - return { resourceGroups }; + return resourceGroups.map((rg: any) => ({ + name: rg.name, + location: rg.location, + })); + } + + /** + * Retrieves the list of Logic Apps (Standard) for the selected subscription. + * @param {string} selectedSubscription - The ID of the selected subscription. + * @returns {Promise>} A promise that resolves to an array of Logic Apps. + */ + async getLogicApps(selectedSubscription: string): Promise { + const headers = this.getAccessTokenHeaders(); + const payload = this.getPayload(ResourceType.logicApps, { selectedSubscription: selectedSubscription }); + const response = await fetch(this.graphApiUri, { headers, method: 'POST', body: JSON.stringify(payload) }); + + if (!response.ok) { + const errorText = `${response.status} ${response.statusText}`; + this.logTelemetryError(errorText); + throw new Error(errorText); + } + + const logicAppsResponse: any = await response.json(); + const { data: logicApps } = logicAppsResponse; + + return logicApps.map((app: any) => ({ + id: app.id, + name: app.siteName, + location: app.location, + subscriptionId: app.subscriptionId, + resourceGroup: app.resourceGroup, + kind: app.kind, + })); + } + + /** + * Retrieves the list of available locations for the selected subscription. + * @param {string} selectedSubscription - The ID of the selected subscription. + * @returns {Promise>} A promise that resolves to an array of locations. + */ + async getLocations(selectedSubscription: string): Promise> { + const locations = await this.getAllRegionWithDisplayName(selectedSubscription); + + return locations.map((loc: any) => ({ + name: loc.name, + displayName: loc.displayName, + })); + } + + /** + * Get all app service plans for selected subscription + */ + async getAppServicePlans(selectedSubscription: string): Promise> { + const headers = this.getAccessTokenHeaders(); + const payload = { + query: `resources | where type =~ 'Microsoft.Web/serverfarms' | sort by (tolower(tostring(name))) asc`, + subscriptions: [selectedSubscription], + }; + + const response = await fetch(this.graphApiUri, { headers, method: HTTP_METHODS.POST, body: JSON.stringify(payload) }); + + if (!response.ok) { + const errorText = `${response.status} ${response.statusText}`; + this.logTelemetryError(errorText); + throw new Error(errorText); + } + + const responseBody = await response.json(); + return responseBody.data.map((plan: any) => ({ + id: plan.id, + name: plan.name, + location: plan.location, + sku: plan.sku, + })); + } + + /** + * Get all storage accounts for selected subscription + */ + async getStorageAccounts(selectedSubscription: string): Promise> { + const headers = this.getAccessTokenHeaders(); + const payload = { + query: `resources | where type =~ 'Microsoft.Storage/storageAccounts' | project id, name, location | sort by (tolower(tostring(name))) asc`, + subscriptions: [selectedSubscription], + }; + + const response = await fetch(this.graphApiUri, { headers, method: HTTP_METHODS.POST, body: JSON.stringify(payload) }); + + if (!response.ok) { + const errorText = `${response.status} ${response.statusText}`; + this.logTelemetryError(errorText); + throw new Error(errorText); + } + + const responseBody = await response.json(); + return responseBody.data.map((storage: any) => ({ + id: storage.id, + name: storage.name, + location: storage.location, + })); + } + + /** + * Check if a storage account name is available (globally unique across Azure) + */ + async checkStorageAccountNameAvailability( + subscriptionId: string, + name: string + ): Promise<{ available: boolean; reason?: string; message?: string }> { + const headers = this.getAccessTokenHeaders(); + const url = `${this.baseGraphApi}/subscriptions/${subscriptionId}/providers/Microsoft.Storage/checkNameAvailability?api-version=2023-01-01`; + const body = JSON.stringify({ name, type: 'Microsoft.Storage/storageAccounts' }); + + const response = await fetch(url, { headers, method: HTTP_METHODS.POST, body }); + + if (!response.ok) { + const errorText = `${response.status} ${response.statusText}`; + this.logTelemetryError(errorText); + throw new Error(errorText); + } + + const result = await response.json(); + return { + available: result.nameAvailable, + reason: result.reason, + message: result.message, + }; } private logTelemetryError = (error: any) => { diff --git a/apps/vs-code-react/src/run-service/types.ts b/apps/vs-code-react/src/run-service/types.ts index 3db975219df..3aa5af2c5de 100644 --- a/apps/vs-code-react/src/run-service/types.ts +++ b/apps/vs-code-react/src/run-service/types.ts @@ -110,6 +110,7 @@ export const ResourceType = { subscriptions: 'subscriptions', ise: 'ise', resourcegroups: 'resourcegroups', + logicApps: 'logicApps', }; export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType]; @@ -157,6 +158,7 @@ export const RouteName = { createWorkspaceFromPackage: 'createWorkspaceFromPackage', createLogicApp: 'createLogicApp', createWorkspaceStructure: 'createWorkspaceStructure', + deploy: 'deploy', }; export type RouteNameType = (typeof RouteName)[keyof typeof RouteName]; diff --git a/apps/vs-code-react/src/state/deploySlice.ts b/apps/vs-code-react/src/state/deploySlice.ts new file mode 100644 index 00000000000..d7f84b83d9f --- /dev/null +++ b/apps/vs-code-react/src/state/deploySlice.ts @@ -0,0 +1,218 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +export interface DeployState { + selectedSubscription: string; + selectedLogicApp: string; + selectedLogicAppName: string; + selectedResourceGroup: string; + selectedSlot?: string; + isLoadingSubscriptions: boolean; + isLoadingLogicApps: boolean; + isCreatingNew: boolean; + newLogicAppName: string; + newResourceGroupName: string; + isCreatingNewResourceGroup: boolean; + selectedLocation: string; + selectedAppServicePlan: string; + newAppServicePlanName: string; + isCreatingNewAppServicePlan: boolean; + selectedAppServicePlanSku: string; + selectedStorageAccount: string; + newStorageAccountName: string; + isCreatingNewStorageAccount: boolean; + createAppInsights: boolean; + newAppInsightsName: string; + appInsightsNameManuallyChanged: boolean; + resourceGroups: Array<{ name: string; location: string }>; + locations: Array<{ name: string; displayName: string }>; + appServicePlans: Array<{ + id: string; + name: string; + location: string; + sku: { name: string; tier: string; capacity: number; family: string; size: string }; + }>; + storageAccounts: Array<{ id: string; name: string; location: string }>; + error?: string; + deploymentFolderPath?: string; + isDeploying: boolean; + deploymentStatus?: 'success' | 'failed'; + deploymentMessage?: string; +} + +const initialState: DeployState = { + selectedSubscription: '', + selectedLogicApp: '', + selectedLogicAppName: '', + selectedResourceGroup: '', + selectedSlot: undefined, + isLoadingSubscriptions: false, + isLoadingLogicApps: false, + isCreatingNew: false, + newLogicAppName: '', + newResourceGroupName: '', + isCreatingNewResourceGroup: false, + selectedLocation: '', + selectedAppServicePlan: '', + newAppServicePlanName: '', + isCreatingNewAppServicePlan: true, + selectedAppServicePlanSku: 'WS1', + selectedStorageAccount: '', + newStorageAccountName: '', + isCreatingNewStorageAccount: true, + createAppInsights: true, + newAppInsightsName: '', + appInsightsNameManuallyChanged: false, + resourceGroups: [], + locations: [], + appServicePlans: [], + storageAccounts: [], + error: undefined, + deploymentFolderPath: undefined, + isDeploying: false, + deploymentStatus: undefined, + deploymentMessage: undefined, +}; + +export const deploySlice = createSlice({ + name: 'deploy', + initialState, + reducers: { + setSelectedSubscription: (state, action: PayloadAction) => { + state.selectedSubscription = action.payload; + state.selectedLogicApp = ''; + state.selectedLogicAppName = ''; + state.selectedResourceGroup = ''; + state.selectedSlot = undefined; + }, + setSelectedLogicApp: (state, action: PayloadAction<{ id: string; name: string; resourceGroup: string }>) => { + state.selectedLogicApp = action.payload.id; + state.selectedLogicAppName = action.payload.name; + state.selectedResourceGroup = action.payload.resourceGroup; + state.isCreatingNew = false; + }, + setIsCreatingNew: (state, action: PayloadAction) => { + state.isCreatingNew = action.payload; + if (action.payload) { + state.selectedLogicApp = ''; + state.selectedLogicAppName = ''; + state.appInsightsNameManuallyChanged = false; + } + }, + setNewLogicAppName: (state, action: PayloadAction) => { + state.newLogicAppName = action.payload; + state.appInsightsNameManuallyChanged = false; + }, + setSelectedResourceGroup: (state, action: PayloadAction) => { + state.selectedResourceGroup = action.payload; + state.isCreatingNewResourceGroup = action.payload === '__CREATE_NEW__'; + }, + setNewResourceGroupName: (state, action: PayloadAction) => { + state.newResourceGroupName = action.payload; + }, + setSelectedLocation: (state, action: PayloadAction) => { + state.selectedLocation = action.payload; + }, + setResourceGroups: (state, action: PayloadAction>) => { + state.resourceGroups = action.payload; + }, + setLocations: (state, action: PayloadAction>) => { + state.locations = action.payload; + }, + setSelectedAppServicePlan: (state, action: PayloadAction) => { + state.selectedAppServicePlan = action.payload; + state.isCreatingNewAppServicePlan = action.payload === '__CREATE_NEW__'; + }, + setNewAppServicePlanName: (state, action: PayloadAction) => { + state.newAppServicePlanName = action.payload; + }, + setSelectedAppServicePlanSku: (state, action: PayloadAction) => { + state.selectedAppServicePlanSku = action.payload; + }, + setAppServicePlans: ( + state, + action: PayloadAction< + Array<{ + id: string; + name: string; + location: string; + sku: { name: string; tier: string; capacity: number; family: string; size: string }; + }> + > + ) => { + state.appServicePlans = action.payload; + }, + setSelectedStorageAccount(state, action: PayloadAction) { + state.selectedStorageAccount = action.payload; + state.isCreatingNewStorageAccount = action.payload === '__CREATE_NEW__'; + }, + setNewStorageAccountName(state, action: PayloadAction) { + state.newStorageAccountName = action.payload; + }, + setCreateAppInsights(state, action: PayloadAction) { + state.createAppInsights = action.payload; + }, + setNewAppInsightsName(state, action: PayloadAction) { + state.newAppInsightsName = action.payload; + state.appInsightsNameManuallyChanged = true; + }, + setStorageAccounts(state, action: PayloadAction>) { + state.storageAccounts = action.payload; + }, + setSelectedSlot: (state, action: PayloadAction) => { + state.selectedSlot = action.payload; + }, + setLoadingSubscriptions: (state, action: PayloadAction) => { + state.isLoadingSubscriptions = action.payload; + }, + setLoadingLogicApps: (state, action: PayloadAction) => { + state.isLoadingLogicApps = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + setDeploymentFolderPath: (state, action: PayloadAction) => { + state.deploymentFolderPath = action.payload; + }, + setDeploying: (state, action: PayloadAction) => { + state.isDeploying = action.payload; + }, + setDeploymentStatus: (state, action: PayloadAction<{ status: 'success' | 'failed'; message?: string }>) => { + state.deploymentStatus = action.payload.status; + state.deploymentMessage = action.payload.message; + state.isDeploying = false; + }, + resetDeployState: () => initialState, + }, +}); + +export const { + setSelectedSubscription, + setSelectedLogicApp, + setIsCreatingNew, + setNewLogicAppName, + setSelectedResourceGroup, + setNewResourceGroupName, + setSelectedLocation, + setResourceGroups, + setLocations, + setSelectedAppServicePlan, + setNewAppServicePlanName, + setSelectedAppServicePlanSku, + setAppServicePlans, + setSelectedStorageAccount, + setNewStorageAccountName, + setCreateAppInsights, + setNewAppInsightsName, + setStorageAccounts, + setSelectedSlot, + setLoadingSubscriptions, + setLoadingLogicApps, + setError, + setDeploymentFolderPath, + setDeploying, + setDeploymentStatus, + resetDeployState, +} = deploySlice.actions; + +export default deploySlice.reducer; diff --git a/apps/vs-code-react/src/state/store.ts b/apps/vs-code-react/src/state/store.ts index 8859c1460c1..8ed7d419062 100644 --- a/apps/vs-code-react/src/state/store.ts +++ b/apps/vs-code-react/src/state/store.ts @@ -5,6 +5,7 @@ import { unitTestSlice } from './UnitTestSlice'; import { workflowSlice } from './WorkflowSlice'; import { projectSlice } from './projectSlice'; import { createWorkspaceSlice } from './createWorkspaceSlice'; +import { deploySlice } from './deploySlice'; import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ @@ -16,6 +17,7 @@ export const store = configureStore({ dataMapDataLoader: dataMapSliceV1.reducer, // Data Mapper V1 dataMap: dataMapSliceV2.reducer, // Data Mapper V2 createWorkspace: createWorkspaceSlice.reducer, + deploy: deploySlice.reducer, }, }); diff --git a/apps/vs-code-react/src/stateWrapper.tsx b/apps/vs-code-react/src/stateWrapper.tsx index 82e6f0397ce..92633a8543a 100644 --- a/apps/vs-code-react/src/stateWrapper.tsx +++ b/apps/vs-code-react/src/stateWrapper.tsx @@ -51,6 +51,10 @@ export const StateWrapper: React.FC = () => { navigate(`/${ProjectName.createWorkspaceStructure}`, { replace: true }); break; } + case ProjectName.deploy: { + navigate(`/${ProjectName.deploy}`, { replace: true }); + break; + } default: { break; } diff --git a/libs/vscode-extension/src/lib/models/extensioncommand.ts b/libs/vscode-extension/src/lib/models/extensioncommand.ts index deae476678a..290b7888ec8 100644 --- a/libs/vscode-extension/src/lib/models/extensioncommand.ts +++ b/libs/vscode-extension/src/lib/models/extensioncommand.ts @@ -67,6 +67,9 @@ export const ExtensionCommand = { package_file: 'package-file', workspace_existence_result: 'workspace-existence-result', package_existence_result: 'package-existence-result', + deploy: 'deploy', + cancel_deploy: 'cancel-deploy', + getFilteredLocations: 'getFilteredLocations', } as const; export type ExtensionCommand = (typeof ExtensionCommand)[keyof typeof ExtensionCommand]; diff --git a/libs/vscode-extension/src/lib/models/project.ts b/libs/vscode-extension/src/lib/models/project.ts index e1b86916797..9e26376c2b0 100644 --- a/libs/vscode-extension/src/lib/models/project.ts +++ b/libs/vscode-extension/src/lib/models/project.ts @@ -17,6 +17,7 @@ export const ProjectName = { createWorkspaceFromPackage: 'createWorkspaceFromPackage', createLogicApp: 'createLogicApp', createWorkspaceStructure: 'createWorkspaceStructure', + deploy: 'deploy', } as const; export type ProjectNameType = (typeof ProjectName)[keyof typeof ProjectName]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 462f53e1d26..e27b6062174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -524,9 +524,15 @@ importers: '@azure/arm-resourcegraph': specifier: ^5.0.0-beta.3 version: 5.0.0-beta.3 + '@azure/arm-resources': + specifier: ^7.0.0 + version: 7.0.0 '@azure/arm-storage': specifier: ^18.1.0 version: 18.6.0 + '@azure/arm-subscriptions': + specifier: ^6.0.0 + version: 6.0.0 '@azure/core-client': specifier: ^1.7.3 version: 1.10.0 @@ -1849,6 +1855,10 @@ packages: resolution: {integrity: sha512-wQyuhL8WQsLkW/KMdik8bLJIJCz3Z6mg/+AKm0KedgK73SKhicSqYP+ed3t+43tLlRFltcrmGKMcHLQ+Jhv/6A==} engines: {node: '>=14.0.0'} + '@azure/arm-resources@7.0.0': + resolution: {integrity: sha512-ezC1YLuPp1bh0GQFALcBvBxAB+9H5O0ynS40jp1t6hTlYe2t61cSplM3M4+4+nt9FCFZOjQSgAwj4KWYb8gruA==} + engines: {node: '>=20.0.0'} + '@azure/arm-storage-profile-2020-09-01-hybrid@2.1.0': resolution: {integrity: sha512-XZYoBWQP9BkQPde5DA7xIiOJVE+6Eeo755VfRqymN42gRn/X6GOcZ0X5x0qvLVxXZcwpFRKblRpkmxGi0FpIxg==} engines: {node: '>=14.0.0'} @@ -1857,6 +1867,10 @@ packages: resolution: {integrity: sha512-dyN50fxts2xClCLIQY8qoDepYx2ql/eW5cVOy8XP+5zt9wIr1cgN2Mmv9/so2HDg6M/zOz8LhrvY+bS2blbhDQ==} engines: {node: '>=20.0.0'} + '@azure/arm-subscriptions@6.0.0': + resolution: {integrity: sha512-QkhMIRvP6koytI5EXC99ZxJDC1eBigYG6KEoHSF1fAKKeQgtZzaczTfX8Pc7zjZpks5J91kMcMk99UEj8+ahPw==} + engines: {node: '>=20.0.0'} + '@azure/core-auth@1.10.1': resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} engines: {node: '>=20.0.0'} @@ -15049,6 +15063,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/arm-resources@7.0.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.22.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/arm-storage-profile-2020-09-01-hybrid@2.1.0': dependencies: '@azure/abort-controller': 1.1.0 @@ -15073,6 +15099,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/arm-subscriptions@6.0.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.22.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/core-auth@1.10.1': dependencies: '@azure/abort-controller': 2.1.2 @@ -23188,7 +23226,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.2)(@vitest/ui@3.2.4)(happy-dom@20.0.2)(jiti@1.21.0)(jsdom@26.1.0)(less@4.2.0)(terser@5.30.2)(yaml@2.7.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.12.7)(@vitest/ui@3.2.4)(happy-dom@20.0.2)(jiti@1.21.0)(jsdom@24.0.0)(less@4.2.0)(terser@5.30.2)(yaml@2.7.0) '@vitest/utils@3.2.4': dependencies: