This commit is contained in:
lucadruda 2022-08-19 00:40:28 +02:00
Родитель af994882bc
Коммит 999774aaa8
21 изменённых файлов: 18363 добавлений и 707 удалений

21
LICENSE Normal file
Просмотреть файл

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Microsoft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Просмотреть файл

@ -1,70 +1,80 @@
# Getting Started with Create React App
# Azure IoT Central device migration tool
A companion experience that enables you to move devices between IoT Central applications and from/to Azure IoT Hub.
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Requirements
- An IoT Central application (or two if moving between applications)
> Go to [IoT Central](https://apps.azureiotcentral.com/home) to create one.
- An Azure Active Directory Application (AAD)
> Follow instructions [here](./docs/appregistration.md).
- An IoT Hub instance (if migrating from/to Azure IoT Hub)
> Go to [Azure Portal > IoT Hub](https://portal.azure.com/#create/Microsoft.IotHub) to create one IoT Hub.
- An Azure IoT Hub Device Provisioning Services (DPS) associated to the IoT Hub instance.
> Go to [Azure Portal > DPS](https://portal.azure.com/#create/Microsoft.IoTDeviceProvisioning) to create one DPS.
## Available Scripts
## Setup
Create a file called _.env_ in project root with following content after completed AAD creation steps above.
In the project directory, you can run:
```ini
PORT=3002
REACT_APP_AAD_APP_CLIENT_ID=<your-AAD-Application-(client)-ID>
REACT_APP_AAD_APP_TENANT_ID=<your-AAD-Directory-(tenant)-ID>
REACT_APP_AAD_APP_REDIRECT_URI=http://localhost:3002
```
### `npm start`
> Make sure that the REACT_APP_AAD_APP_REDIRECT_URI in the config file and the Redirect URIs specified in your AAD Application are the same.
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
### Model requirements
To successfully perform a migration from an IoT Central application, the template for the devices to be migrated must comply to a specific configuration. The easiest and quickest way to define required capabilities is to import the _DeviceMigration_ component into the template.
The page will reload when you make changes.\
You may also see any lint errors in the console.
You can find the json file [here](./assets/deviceMigrationComponent.json).
### `npm test`
Follow instructions on [IoT Central official documentation](https://docs.microsoft.com/en-us/azure/iot-central/core/howto-set-up-template#interfaces-and-components).
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
## Run
You can run the tool in development mode using:
```sh
npm start
```
- Open in the browser the AADRedirectURI url previously defined in your _.env_ (by default is http://localhost:3002) to view it in the browser.
### `npm run build`
- Authenticate the user.
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
### Options
1. __IoT Central -> IoT Hub__
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
Pick the needed values from source and target forms. Provide a name and click on _Migrate_ button.
### `npm run eject`
Follow instructions on the page to create a new enrollment group in the DPS instance then click on _Start migration_.
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
<img src='./assets/c2h.png' width='500px'/>
<img src='./assets/copykeys.png' width='500px'/>
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
2. __IoT Central -> IoT Central__
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
Pick the needed values from source and target forms.
Optionally specify a target template to automatically assign migrating devices to a particular template in the application.
Provide a name and click on _Migrate_ button.
Wait for the migration job to be configured. After that the job will automatically start and you can follow the progress in the _Migration status_ page.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
<img src='./assets/c2c.png' width='500px'/>
## Learn More
3. __IoT Hub -> IoT Central__
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
Select the hubs from which migrating devices, provide a name and click on _Migrate_ button. The job has been configured and available in the _Migration status_ page. Once there click on "Run" and provide the required keys following instructions.
### Code Splitting
<img src='./assets/h2c.png' width='500px'/>
<img src='./assets/status.png' width='500px'/>
<img src='./assets/form.png' width='500px'/>
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
Wait for the job to complete.
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Codebase
_iotc-migrator_ is a React SPA application written in Typescript that runs 100% in the browser. It was bootstrapped with Create React App.
### Making a Progressive Web App
The project consume public Azure APIs on the latest stable versions.
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
The Authentication is performed using Microsoft Authentication Library (MSAL).
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

Двоичные данные
assets/c2c.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 86 KiB

Двоичные данные
assets/c2h.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 82 KiB

Двоичные данные
assets/copykeys.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 149 KiB

Просмотреть файл

@ -0,0 +1,78 @@
{
"@id": "dtmi:azureiot:DeviceMigration;1",
"@type": "Interface",
"contents": [
{
"@type": "Command",
"commandType": "synchronous",
"displayName": {
"en": "DeviceMove"
},
"name": "DeviceMove",
"request": {
"@type": "CommandPayload",
"displayName": {
"en": "Data"
},
"name": "data",
"schema": {
"@type": "Object",
"displayName": {
"en": "Object"
},
"fields": [
{
"displayName": {
"en": "Id Scope"
},
"name": "idScope",
"schema": "string"
},
{
"displayName": {
"en": "DPS name"
},
"name": "dpsName",
"schema": "string"
},
{
"displayName": {
"en": "DPS Id"
},
"name": "dpsId",
"schema": "string"
},
{
"displayName": {
"en": "IoT Central Destination Name"
},
"name": "centralAppName",
"schema": "string"
},
{
"displayName": {
"en": "IoT Central Destination Subdomain"
},
"name": "centralAppSubdomain",
"schema": "string"
},
{
"displayName": {
"en": "IoT Central Destination Template"
},
"name": "deviceTemplateId",
"schema": "string"
}
]
}
}
}
],
"displayName": {
"en": "Device Migration"
},
"@context": [
"dtmi:iotcentral:context;2",
"dtmi:dtdl:context;2"
]
}

Двоичные данные
assets/form.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 104 KiB

Двоичные данные
assets/h2c.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 83 KiB

Двоичные данные
assets/manifest.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 97 KiB

Двоичные данные
assets/status.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 72 KiB

46
docs/appregistration.md Normal file
Просмотреть файл

@ -0,0 +1,46 @@
1. Go to [Azure Portal > Azure Active Directory > App Registration](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps)
2. Click on _New Registration_
![New registration](/assets/registerApp.png)
3. Fill the form using making sure to put in the `Redirect URI` the same value defined in the `.env` file under _REACT_APP_AAD_APP_REDIRECT_URI_
![Create app](/assets/newApp.png)
4. Click _Register_ and once the app is created you will get the paramaters needed in the `.env` file.
![App created](/assets/appCreated.png)
5. Go to _Manifest_ view and add the required permissions in the _requiredResourceAccess_ list.
![Manifest](/assets/manifest.png)
```json
[
{
"resourceAppId": "9edfcdd9-0bc5-4bd4-b287-c3afc716aac7",
"resourceAccess": [
{
"id": "73792908-5709-46da-9a68-098589599db6",
"type": "Scope"
}
]
},
{
"resourceAppId": "797f4846-ba00-4fd7-ba43-dac1f8f63013",
"resourceAccess": [
{
"id": "41094075-9dad-400e-a0bd-54e686782033",
"type": "Scope"
}
]
},
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{
"id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d",
"type": "Scope"
}
]
}
]
```

17988
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -6,7 +6,6 @@
"@azure/msal-browser": "^2.26.0",
"@fluentui/react": "^8.77.0",
"@fluentui/react-hooks": "^8.6.0",
"axios": "^0.27.2",
"crypto-js": "^4.1.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
@ -17,7 +16,8 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"format": "prettier --write --loglevel warn \"**/*.{js,json,md,ts,yml,yaml}\""
},
"eslintConfig": {
"extends": [

Просмотреть файл

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta

Просмотреть файл

@ -17,11 +17,18 @@
.height100 {
height: 100%;
}
.height300 {
height: 300px;
}
.width100 {
width: 100%;
}
.width90 {
width: 90%;
}
.signOut {
cursor: pointer;
}
@ -80,6 +87,10 @@
justify-content: center;
}
.flex.horizontal.between {
justify-content: space-between;
}
.section {
padding: 2em;
border: 1px solid gray;

Просмотреть файл

@ -8,12 +8,13 @@ import {
} from '@fluentui/react'
import { useBoolean } from '@fluentui/react-hooks'
import React, { useCallback, useEffect, useState } from 'react'
import { login, loginSilent, logout } from './api'
import { getDPSKeys, login, loginSilent, logout } from './api'
import './App.css'
import { NavigatorItem, Navigator } from './navigation'
import MigrationStatus from './pages/migrationStatus'
import NewMigration from './pages/newMigration'
import { ApiError } from './types'
import { generateSaSToken } from './utils'
const iconButtonStyles: Partial<IButtonStyles> = {
root: {
@ -43,6 +44,8 @@ export default function App() {
auth = (await login()).account
}
setAccount(auth)
const keys=await getDPSKeys('/subscriptions/2efa8bb6-25bf-4895-ba64-33806dd00780/resourceGroups/paas/providers/Microsoft.Devices/provisioningServices/migratordps', 'provisioningserviceowner');
console.log(generateSaSToken('migratordps.azure-devices-provisioning.net',keys.primaryKey,'provisioningserviceowner'));
}, [])
const setModalMessage = useCallback((msg: ApiError | JSX.Element) => {

Двоичные данные
src/addgroup.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 114 KiB

Просмотреть файл

@ -13,7 +13,26 @@ import {
EnrollmentGroup,
} from './types'
import { v4 as uuid } from 'uuid'
import axios from 'axios'
//#region apitypes
type EnrollmentGroupResponse = {
id: string
displayName: string
enabled: boolean
type: 'iot' | 'iotEdge'
attestation: {
type: 'symmetricKey' | 'x509'
symmetricKey?: {
primaryKey: string
secondaryKey: string
}
x509?: {
signingCertificates: any
}
}
etag: string
}
//#endregion
export const msalConfig = {
auth: {
@ -64,7 +83,6 @@ let account: AccountInfo | null
function getAccount(): AccountInfo | null {
// need to call getAccount here?
const cache = msalInstance.getTokenCache()
const currentAccounts = msalInstance.getAllAccounts()
if (currentAccounts === null) {
@ -163,55 +181,6 @@ export async function getDPSKeys(dpsId: string, policyName: string) {
return dpsResp.json()
}
export async function getDPSEnrollmentKeys(
dpsHost: string,
sasToken: string
): Promise<{ primaryKey: string; secondaryKey: string }> {
const params = {
method: 'POST',
crossDomain: true,
headers: {
Authorization: sasToken,
'Content-Type': 'application/json',
Accept: '*/*',
Host: dpsHost,
},
body: JSON.stringify({
query: '*',
}),
}
const resp = await axios.post(
`https://${dpsHost}/enrollmentGroups/query?api-version=${API_VERSIONS.DPSData}`,
{
query: '*',
},
{
withCredentials: false,
headers: {
Authorization: sasToken,
'Content-Type': 'application/json',
},
}
)
const listResp = await fetch(
`https://${dpsHost}/enrollmentGroups/query?api-version=${API_VERSIONS.DPSData}`,
params
)
const groups = await listResp.json()
const group = groups.find((g: any) => g.attestation.type === 'symmetricKey')
if (!group) {
throw new ApiError(
'Failed to query DPS',
'Cannot find an enrollment group with symmetric keys.'
)
}
const enrollMentResp = await fetch(
`https://${dpsHost}/enrollmentGroups/${group.enrollmentGroupId}attestationmechanism?api-version=${API_VERSIONS.DPSData}`,
params
)
return (await enrollMentResp.json()).symmetricKey
}
export async function listDPSs() {
const armToken = await getToken(
'https://management.azure.com/user_impersonation'
@ -253,6 +222,48 @@ export async function listDPSs() {
return data
}
export async function createDpsEnrollment(
dpsName: string,
dpsId: string,
enrollment: EnrollmentGroup,
iotHubs: string[]
) {
const enrollmentGroupId = uuid()
const params = {
method: 'PUT',
headers: {
Authorization: await getDPSKeys(dpsId, 'provisioningserviceowner'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
enrollmentGroupId,
attestation: {
type: 'symmetricKey',
symmetricKey: {
primaryKey: enrollment.primaryKey,
secondaryKey: enrollment.secondaryKey,
},
},
capabilities: {
iotEdge: false,
},
reprovisionPolicy: {
updateHubAssignment: true,
migrateDeviceData: true,
},
allocationPolicy: 'hashed',
iotHubs: [],
}),
}
const resp = await fetch(
`https://${dpsName}.azure-devices-provisioning.net/enrollmentGroups/${enrollmentGroupId}?api-version=${API_VERSIONS.DPSData}`,
params
)
const data = await resp.json()
if (!resp.ok) {
throw new ApiError('Error working on DPS', data)
}
}
export async function listHubs() {
const armToken = await getToken(
'https://management.azure.com/user_impersonation'
@ -319,7 +330,8 @@ export async function invokeCommand(
hubHost: string,
sasToken: string,
deviceId: string,
idScope: string
idScope: string,
deviceTemplateId?: string
) {
const params = {
method: 'POST',
@ -329,7 +341,7 @@ export async function invokeCommand(
},
body: JSON.stringify({
methodName: 'DeviceMove',
payload: idScope,
payload: JSON.stringify({ idScope, deviceTemplateId }),
}),
}
const cmdResp = await fetch(
@ -373,7 +385,7 @@ export async function listCentralApps() {
return resources.flat()
}
export async function listDeviceGroups(appDomain: string) {
export async function listDeviceGroups(appSubdomain: string) {
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
const params = {
method: 'GET',
@ -382,17 +394,18 @@ export async function listDeviceGroups(appDomain: string) {
},
}
const groups = await fetch(
`https://${appDomain}.azureiotcentral.com/api/deviceGroups?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/deviceGroups?api-version=${API_VERSIONS.Central}`,
params
)
return (await groups.json()).value
}
export async function createCentralEnrollment(
appDomain: string,
appSubdomain: string,
symmetricKey: { primaryKey: string; secondaryKey: string }
) {
): Promise<EnrollmentGroup> {
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
let resp
const params = {
method: 'PUT',
headers: {
@ -410,21 +423,53 @@ export async function createCentralEnrollment(
}),
}
const enrollmentGroup = await fetch(
`https://${appDomain}.azureiotcentral.com/api/enrollmentGroups/${uuid()}?api-version=${
`https://${appSubdomain}.azureiotcentral.com/api/enrollmentGroups/${uuid()}?api-version=${
API_VERSIONS.Central
}`,
params
)
if (!enrollmentGroup.ok) {
if (enrollmentGroup.status === 409) return
if (enrollmentGroup.status === 409) {
// fetch the group
const params = {
method: 'GET',
headers: {
Authorization: `Bearer ${centralToken}`,
},
}
const enrollmentGroups: { value: EnrollmentGroupResponse[] } =
await (
await fetch(
`https://${appSubdomain}.azureiotcentral.com/api/enrollmentGroups?api-version=${API_VERSIONS.Central}`,
params
)
).json()
resp = enrollmentGroups.value.find(
(enrl) =>
enrl.type === 'iot' &&
enrl.attestation.type === 'symmetricKey' &&
enrl.attestation.symmetricKey?.primaryKey ===
symmetricKey.primaryKey &&
enrl.attestation.symmetricKey.secondaryKey ===
symmetricKey.secondaryKey
)
if (!resp) {
throw new ApiError(
'IoT Central error',
'Cannot create enrollment group on target application'
)
}
}
} else {
resp = await enrollmentGroup.json()
}
return {
idScope: await _getCentralIdScope(appSubdomain, centralToken),
...resp.attestation.symmetricKey!,
}
return enrollmentGroup.json()
}
export async function getCentralIdScope(
appDomain: string,
centralToken?: string
) {
async function _getCentralIdScope(appSubdomain: string, centralToken?: string) {
// lack of support for getting id scope. creating a temp device to fetch it
const bearer = centralToken || (await getToken(TOKEN_AUDIENCES.Central))
@ -442,7 +487,7 @@ export async function getCentralIdScope(
}),
}
const tempDev = await fetch(
`https://${appDomain}.azureiotcentral.com/api/devices/${tempDevId}?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/devices/${tempDevId}?api-version=${API_VERSIONS.Central}`,
createparams
)
if (!tempDev.ok) {
@ -458,7 +503,7 @@ export async function getCentralIdScope(
},
}
const creds = await fetch(
`https://${appDomain}.azureiotcentral.com/api/devices/${tempDevId}/credentials?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/devices/${tempDevId}/credentials?api-version=${API_VERSIONS.Central}`,
getParams
)
if (!creds.ok) {
@ -477,7 +522,7 @@ export async function getCentralIdScope(
},
}
const deleteDev = await fetch(
`https://${appDomain}.azureiotcentral.com/api/devices/${tempDevId}?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/devices/${tempDevId}?api-version=${API_VERSIONS.Central}`,
deleteparams
)
if (!deleteDev.ok) {
@ -490,9 +535,9 @@ export async function getCentralIdScope(
}
export async function getCentralEnrollment(
appDomain: string,
appSubdomain: string,
centralToken?: string
): Promise<EnrollmentGroup> {
) {
const bearer = centralToken || (await getToken(TOKEN_AUDIENCES.Central))
const getParams = {
method: 'GET',
@ -501,29 +546,40 @@ export async function getCentralEnrollment(
},
}
const enrollments = await fetch(
`https://${appDomain}.azureiotcentral.com/api/enrollmentGroups?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/enrollmentGroups?api-version=${API_VERSIONS.Central}`,
getParams
)
if (!enrollments.ok) {
throw new ApiError('IoT Central error', 'Cannot list enrollment groups')
}
const enrollmentGroups = (await enrollments.json()).value
const attestation = enrollmentGroups.find(
const enrollmentGroups: EnrollmentGroupResponse[] = (
await enrollments.json()
).value
const group = enrollmentGroups.find(
(e: any) => e.type === 'iot' && e.attestation.type === 'symmetricKey'
)
if (!attestation) {
if (!group) {
throw new ApiError(
'IoT Central error',
'Cannot find an enrollment group with symmetric keys attestation'
)
}
return group.attestation.symmetricKey
}
export async function getCentralCredentials(
appSubdomain: string,
centralToken?: string
): Promise<EnrollmentGroup> {
const bearer = centralToken || (await getToken(TOKEN_AUDIENCES.Central))
const enrollment = await getCentralEnrollment(appSubdomain, bearer)
return {
idScope: await getCentralIdScope(appDomain, bearer),
...attestation.symmetricKey,
idScope: await _getCentralIdScope(appSubdomain, bearer),
...enrollment!,
}
}
export async function listJobs(appDomain: string) {
export async function listJobs(appSubdomain: string) {
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
const params = {
method: 'GET',
@ -532,14 +588,14 @@ export async function listJobs(appDomain: string) {
},
}
const jobs = await fetch(
`https://${appDomain}.azureiotcentral.com/api/jobs?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/jobs?api-version=${API_VERSIONS.Central}`,
params
)
const res: JobResult[] = (await jobs.json()).value
return res.filter((r) => r.description === JOB_DESCRIPTION)
}
export async function listDeviceTemplates(appDomain: string) {
export async function listDeviceTemplates(appSubdomain: string) {
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
const params = {
method: 'GET',
@ -548,7 +604,7 @@ export async function listDeviceTemplates(appDomain: string) {
},
}
const templates = await fetch(
`https://${appDomain}.azureiotcentral.com/api/deviceTemplates?api-version=${API_VERSIONS.Central}`,
`https://${appSubdomain}.azureiotcentral.com/api/deviceTemplates?api-version=${API_VERSIONS.Central}`,
params
)
return (await templates.json()).value
@ -556,13 +612,13 @@ export async function listDeviceTemplates(appDomain: string) {
/**
*
* @param appDomain
* @param appSubdomain
* @param migrationData
* @returns migrationStatus
* @throws ApiError
*/
export async function createMigrationJob(
appDomain: string,
appSubdomain: string,
migrationData: JobPayload
) {
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
@ -575,7 +631,7 @@ export async function createMigrationJob(
},
}
const job = await fetch(
`https://${appDomain}.azureiotcentral.com/api/jobs/${uuid()}?api-version=${
`https://${appSubdomain}.azureiotcentral.com/api/jobs/${uuid()}?api-version=${
API_VERSIONS.Central
}`,
params

Просмотреть файл

@ -3,6 +3,7 @@ import {
Coachmark,
ConstrainMode,
DetailsList,
DetailsListLayoutMode,
IColumn,
Icon,
IconButton,
@ -19,7 +20,6 @@ import { useBoolean } from '@fluentui/react-hooks'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
createCentralEnrollment,
getCentralIdScope,
getHubKeys,
invokeCommand,
listCentralApps,
@ -38,7 +38,7 @@ import enrollmentScreen from '../enrollment.png'
const gridStyles: Partial<IDetailsListStyles> = {
root: {
overflowX: 'auto',
overflowX: 'hidden',
selectors: {
'& [role=grid]': {
display: 'flex',
@ -48,13 +48,8 @@ const gridStyles: Partial<IDetailsListStyles> = {
},
},
},
headerWrapper: {
flex: '0 0 auto',
},
contentWrapper: {
flex: '1 1 auto',
overflowY: 'auto',
overflowX: 'hidden',
},
}
@ -82,6 +77,11 @@ const centralColumns: IColumn[] = [
name: 'DPS',
minWidth: 200,
},
{
key: 'centralTarget',
name: 'IoT Central target application',
minWidth: 200,
},
{
key: 'jobStatus',
name: 'Migration Status',
@ -116,8 +116,8 @@ const hubColumns: IColumn[] = [
{
key: 'progress',
name: '',
maxWidth: 200,
minWidth: 200,
maxWidth: 200,
},
]
@ -135,14 +135,71 @@ function _onRenderCentralItemColumn(
</a>
)
case 'dpsName':
if (item?.data[0].value.dpsName) {
return (
<a
href={item?.data[0].value.dpsId}
target='_blank'
rel='noreferrer'
>
{item?.data[0].value.dpsName}
</a>
)
}
return null
case 'centralTarget':
if (
item?.data[0].value.centralAppName &&
item?.data[0].value.centralAppSubdomain
) {
return (
<a
href={item?.data[0].value.centralAppName}
target='_blank'
rel='noreferrer'
>
{`https://${item?.data[0].value.centralAppSubdomain}.azureiotcentral.com`}
</a>
)
}
return null
case 'jobStatus':
const progress = item?.progress!
if (progress.pending > 0) {
return (
<div className='flex horizontal between' id={item?.id}>
<span className='spaced-right'>Pending</span>
<Icon
iconName='Running'
className='icon'
style={{ color: 'grey' }}
/>
</div>
)
} else if (progress.failed > 0) {
return (
<div className='flex horizontal between' id={item?.id}>
<span className='spaced-right'>Failed</span>
<Icon
iconName='Error'
className='icon'
style={{ color: 'red' }}
/>
</div>
)
}
return (
<a
href={item?.data[0].value.dpsId}
target='_blank'
rel='noreferrer'
>
{item?.data[0].value.dpsName}
</a>
<div className='flex horizontal between' id={item?.id}>
<span className='spaced-right'>Completed</span>
<Icon
iconName='Completed'
className='icon'
style={{ color: 'green' }}
/>
</div>
)
default:
return <span>{fieldContent}</span>
@ -165,10 +222,9 @@ export default React.memo<{
onDismiss: () => Promise<void> | void
} | null>(null)
const [
coachmarkShown,
{ setTrue: showCoachmark, setFalse: hideCoachmark },
] = useBoolean(!!gotoItemId)
const [coachmarkShown, { setFalse: hideCoachmark }] = useBoolean(
!!gotoItemId
)
const progressStyles = useMemo(
() => ({
@ -222,7 +278,7 @@ export default React.memo<{
</div>
<div className='bottom-margin'>
<span>
1. Open the DPS instance page on the Azure Potal (
1. Open the DPS instance page on the Azure Portal (
<a
href={hubItem.dpsLink}
target='_blank'
@ -297,8 +353,10 @@ export default React.memo<{
)
const devices = await listDevicesInHub(hubJob.hubHost, hubSas)
await createCentralEnrollment(hubJob.appHost, hubJob.enrollment!)
const centralIdScope = await getCentralIdScope(hubJob.appHost)
const targetEnrollment = await createCentralEnrollment(
hubJob.appHost,
hubJob.enrollment!
)
try {
await Promise.all(
devices.map(async (device) => {
@ -306,7 +364,7 @@ export default React.memo<{
hubJob.hubHost,
hubSas,
device.deviceId,
centralIdScope
targetEnrollment.idScope
)
})
)
@ -316,7 +374,7 @@ export default React.memo<{
changeStatus(itemIdx, HubJobStatus.FAILED)
}
},
[changeStatus]
[changeStatus, setErrorMessage]
)
const _onRenderHubItemColumn = useCallback(
@ -413,7 +471,7 @@ export default React.memo<{
)
}
},
[changeStatus, generateContent, loadDevices, openModal]
[changeStatus, generateContent, loadDevices, openModal, progressStyles]
)
const fetchJobs = useCallback(async () => {
@ -444,7 +502,7 @@ export default React.memo<{
<h2>Migration status</h2>
<div className='formHeader center-vertical'>
<span>
Watch all the IoT Central to IoT Hub migration processes.
Watch all the migrations from IoT Central applications.
</span>
<IconButton
iconProps={{ iconName: 'Sync' }}
@ -454,31 +512,37 @@ export default React.memo<{
}}
/>
</div>
<div className='bottom-margin width100'>
<ShimmeredDetailsList
columns={centralColumns}
items={centralItems}
styles={gridStyles}
skipViewportMeasures
enableShimmer={centralItems.length === 0}
constrainMode={ConstrainMode.unconstrained}
selectionMode={SelectionMode.none}
onRenderItemColumn={_onRenderCentralItemColumn}
/>
</div>
<div className='formHeader center-vertical'>
<span>Manage IoTHub to IoT Central migration processes.</span>
</div>
<div className='bottom-margin width100'>
<DetailsList
columns={hubColumns}
items={hubItems}
skipViewportMeasures
selectionMode={SelectionMode.none}
constrainMode={ConstrainMode.unconstrained}
onRenderItemColumn={_onRenderHubItemColumn}
styles={gridStyles}
/>
<div className='width90'>
<div className='bottom-margin'>
<DetailsList
columns={centralColumns}
items={centralItems}
styles={gridStyles}
skipViewportMeasures
layoutMode={DetailsListLayoutMode.fixedColumns}
// enableShimmer={centralItems.length === 0}
constrainMode={ConstrainMode.unconstrained}
selectionMode={SelectionMode.none}
onRenderItemColumn={_onRenderCentralItemColumn}
/>
</div>
<div className='formHeader center-vertical'>
<span>
Watch and manage migrations from DPS and IoTHub.
</span>
</div>
<div className='bottom-margin'>
<DetailsList
columns={hubColumns}
items={hubItems}
layoutMode={DetailsListLayoutMode.fixedColumns}
skipViewportMeasures
selectionMode={SelectionMode.none}
constrainMode={ConstrainMode.unconstrained}
onRenderItemColumn={_onRenderHubItemColumn}
styles={gridStyles}
/>
</div>
</div>
{coachmarkShown && (
<Coachmark
@ -494,10 +558,6 @@ export default React.memo<{
{modalData?.content}
</div>
</Modal>
{/* <PrimaryButton
onClick={() => openModal({ dpsLink: '' } as any, 0)}
text='open'
/> */}
</div>
)
})

Просмотреть файл

@ -24,7 +24,10 @@ import {
listDeviceTemplates,
createMigrationJob,
listHubs,
getDPSKeys,
getCentralCredentials,
createCentralEnrollment,
createDpsEnrollment,
getCentralEnrollment,
} from '../api'
import {
FormValues,
@ -41,8 +44,12 @@ import {
STORAGE_ITEM,
HubJob,
HubJobStatus,
CentralSourceParams,
CentralTargetParams,
DPSTargetParams,
} from '../types'
import { filterHubs, findComponent, generateSaSToken } from '../utils'
import { filterHubs, findComponent } from '../utils'
import enrollmentScreen from '../addgroup.png'
const iconButtonStyles: Partial<IButtonStyles> = {
root: {
@ -108,6 +115,7 @@ export default React.memo<{
const dpsId = useId()
const deviceGroupsId = useId()
const deviceTemplateId = useId()
const deviceTemplateId2 = useId()
const [values, setValues] = useState<FormValues>({
name: '',
@ -124,13 +132,20 @@ export default React.memo<{
const [modalOpen, { setFalse: closeModal, setTrue: openModal }] =
useBoolean(false)
const [modalData, setModalData] = useState<JSX.Element | null>(null)
const [modalData, setModalData] = useState<{
content: JSX.Element
onDismiss: () => Promise<void> | void
} | null>(null)
//#region DROPDOWN ITEMS LOADING STATE
const [
loadingApps,
{ setTrue: startLoadingApps, setFalse: stopLoadingApps },
] = useBoolean(false)
const [
loadingApps2,
{ setTrue: startLoadingApps2, setFalse: stopLoadingApps2 },
] = useBoolean(false)
const [
loadingDPSs,
{ setTrue: startLoadingDPSs, setFalse: stopLoadingDPSs },
@ -143,6 +158,10 @@ export default React.memo<{
loadingTemplates,
{ setTrue: startLoadingTemplates, setFalse: stopLoadingTemplates },
] = useBoolean(false)
const [
loadingTemplates2,
{ setTrue: startLoadingTemplates2, setFalse: stopLoadingTemplates2 },
] = useBoolean(false)
const [
loadingHubs,
@ -159,6 +178,10 @@ export default React.memo<{
const [deviceTemplates, setDeviceTemplates] = React.useState<
IDropdownOption[]
>([])
const [deviceTemplates2, setDeviceTemplates2] = React.useState<
IDropdownOption[]
>([])
//#endregion
//#region DATA LOADING CALLBACK
@ -169,6 +192,13 @@ export default React.memo<{
)
}, [loadingApps])
const onCentralAppLoading2 = React.useCallback(() => {
return _onDataLoading(
'Select an IoT Central application...',
loadingApps2
)
}, [loadingApps2])
const onDPSLoading = React.useCallback(() => {
return _onDataLoading('Select an IoT DPS instance...', loadingDPSs)
}, [loadingDPSs])
@ -180,6 +210,10 @@ export default React.memo<{
const onTemplatesLoading = React.useCallback(() => {
return _onDataLoading('Select a device template...', loadingTemplates)
}, [loadingTemplates])
const onTemplatesLoading2 = React.useCallback(() => {
return _onDataLoading('Select a device template...', loadingTemplates2)
}, [loadingTemplates2])
//#endregion
//#region DROPDOWNS IN OPENING STATE
@ -194,6 +228,17 @@ export default React.memo<{
}
}, [centralApps, startLoadingApps, stopLoadingApps])
const onCentralDropDownOpen2 = React.useCallback(async () => {
if (centralApps.length === 0) {
await _onDropDownOpen(
listCentralApps,
setCentralApps,
startLoadingApps2,
stopLoadingApps2
)
}
}, [centralApps, startLoadingApps2, stopLoadingApps2])
const onDpsDropDownOpen = React.useCallback(async () => {
if (DPSs.length === 0) {
await _onDropDownOpen(
@ -214,22 +259,29 @@ export default React.memo<{
startLoadingGroups,
stopLoadingGroups
)
}, [startLoadingGroups, stopLoadingGroups, values.source])
}, [startLoadingGroups, stopLoadingGroups, values.source.id])
const onDeviceTemplateDropDownOpen = React.useCallback(async () => {
await _onDropDownOpen(
() => {
const appId =
values.mode === MigrationMode.ToHub
? values.source.id
: values.target.id
return listDeviceTemplates(appId)
return listDeviceTemplates(values.source.id)
},
setDeviceTemplates,
startLoadingTemplates,
stopLoadingTemplates
)
}, [startLoadingTemplates, stopLoadingTemplates, values])
}, [startLoadingTemplates, stopLoadingTemplates, values.source.id])
const onDeviceTemplateDropDownOpen2 = React.useCallback(async () => {
await _onDropDownOpen(
() => {
return listDeviceTemplates(values.target.id)
},
setDeviceTemplates2,
startLoadingTemplates2,
stopLoadingTemplates2
)
}, [startLoadingTemplates2, stopLoadingTemplates2, values.target.id])
//#endregion
@ -270,24 +322,6 @@ export default React.memo<{
[setValues]
)
const setDPSTargetService = React.useCallback(
(target: RecursivePartial<DPSTargetService>) => {
setValues((cur) => ({
...cur,
target: {
...cur.target,
...target,
type: ServiceType.DPS,
params: {
...cur.target.params,
...target.params,
},
} as DPSTargetService,
}))
},
[setValues]
)
const setDPSSourceService = React.useCallback(
(source: RecursivePartial<DPSSourceService>) => {
setValues((cur) => ({
@ -331,7 +365,7 @@ export default React.memo<{
)
//#endregion
//#region GENERAL CALLBACKS
const onDeviceTemplateSelected = React.useCallback(
const onSourceDeviceTemplateSelected = React.useCallback(
async (template) => {
const { capabilityModel } = template
const migrationComponent = findComponent(
@ -393,19 +427,150 @@ export default React.memo<{
) {
// create migration job
try {
await createMigrationJob(values.source.id, {
displayName: values.name,
group: values.source.params.groupId,
description: JOB_DESCRIPTION,
data: [
{
type: 'command',
target: values.source.params.deviceTemplateId,
path: `${values.source.params.componentName}.DeviceMove`,
value: values.target.params,
},
],
const enrollmentGroup = await getCentralEnrollment(
values.source.id
)
setModalData({
content: (
<div className='flex vertical center-horizontal'>
<div className='bottom-margin'>
<span className='bold'>
Follow below instructions to complete job
setup.
</span>
</div>
<div className='bottom-margin'>
<span>
1. Open the DPS instance page on the Azure
Portal (
<a
href={values.target.params.dpsId}
target='_blank'
rel='noreferrer'
>
Click here
</a>
) .<br />
2. Head to the "Manage Enrollments" section
on the left menu.
<br />
3. Create a new enrollment group of
"SymmetricKey" type and copy/paste the
following keys.
<br />
4. Click "Start migration" to start the
migration job
<br />
</span>
</div>
<Stack
tokens={{ childrenGap: 15 }}
className='flex vertical'
>
<TextField
label='Primary Key'
value={enrollmentGroup?.primaryKey}
readOnly
onRenderSuffix={() => (
<IconButton
iconProps={{
iconName: 'Copy',
}}
styles={{
rootHovered: {
cursor: 'pointer',
backgroundColor:
'transparent',
},
rootPressed: {
backgroundColor: 'white',
height: '-webkit-fill-available',
},
}}
onClick={async () => {
await navigator.clipboard.writeText(
enrollmentGroup?.primaryKey!
)
}}
/>
)}
/>
<TextField
label='Secondary Key'
value={enrollmentGroup?.secondaryKey}
readOnly
onRenderSuffix={() => (
<IconButton
iconProps={{
iconName: 'Copy',
}}
styles={{
rootHovered: {
cursor: 'pointer',
backgroundColor:
'transparent',
},
rootPressed: {
backgroundColor: 'white',
height: '-webkit-fill-available',
},
}}
onClick={async () => {
await navigator.clipboard.writeText(
enrollmentGroup?.secondaryKey!
)
}}
/>
)}
/>
<div className='modal-image center-horizontal'>
<img
alt='screen'
src={enrollmentScreen}
className='width90'
/>
</div>
<div className='center-horizontal'>
<PrimaryButton
className='button'
text='Start migration'
onClick={() => {
closeModal()
}}
/>
</div>
</Stack>
</div>
),
onDismiss: async () => {
const sourceParams = values.source
.params as CentralSourceParams
const targetParams = values.target
.params as DPSTargetParams
await createMigrationJob(values.source.id, {
displayName: values.name,
group: sourceParams.groupId,
description: JOB_DESCRIPTION,
data: [
{
type: 'command',
target: sourceParams.deviceTemplateId,
path: `${sourceParams.componentName}.DeviceMove`,
value: {
idScope: targetParams.idScope,
dpsId: targetParams.dpsId,
dpsName: targetParams.dpsName,
centralAppName: values.source.name,
centralAppSubdomain: values.source.id,
},
},
],
})
stopSubmit()
},
})
openModal()
} catch (err) {
setErrorMessage(err as ApiError)
}
@ -435,38 +600,85 @@ export default React.memo<{
})
)
localStorage.setItem(STORAGE_ITEM, JSON.stringify(currentStorage))
setModalData(
<div className='flex vertical center-horizontal'>
<div className='center-vertical bold'>
Ready
<IconButton
iconProps={{ iconName: 'Cancel' }}
styles={iconButtonStyles}
ariaLabel='Close popup modal'
onClick={closeModal}
/>
setModalData({
content: (
<div className='flex vertical center-horizontal'>
<div className='center-vertical bold'>
Ready
<IconButton
iconProps={{ iconName: 'Cancel' }}
styles={iconButtonStyles}
ariaLabel='Close popup modal'
onClick={closeModal}
/>
</div>
<div className='bottom-margin'>
<span>
The migration job has been configured.
<br /> Go to the "Migration status" tab or{' '}
<Link
onClick={() => {
closeModal()
goToStatus(id)
}}
>
click here
</Link>{' '}
to run the job.
</span>
</div>
</div>
<div className='bottom-margin'>
<span>
The migration job has been configured.<br/> Go to the
"Migration status" tab or {' '}
<Link
onClick={() => {
closeModal()
goToStatus(id)
}}
>
click here
</Link>
{' '} to run the job.
</span>
</div>
</div>
)
),
onDismiss: () => {
stopSubmit()
},
})
openModal()
} else {
// central-to-central
// create migration job
try {
const sourceParams = values.source.params as CentralSourceParams
const targetParams = values.target.params as CentralTargetParams
const sourceEnrollment = await getCentralCredentials(
values.source.id
)
const targetEnrollment = await createCentralEnrollment(
values.target.id,
sourceEnrollment
)
await createMigrationJob(values.source.id, {
displayName: values.name,
group: sourceParams.groupId,
description: JOB_DESCRIPTION,
data: [
{
type: 'command',
target: sourceParams.deviceTemplateId,
path: `${sourceParams.componentName}.DeviceMove`,
value: {
idScope: targetEnrollment.idScope,
centralAppName: values.target.name,
centralAppSubdomain: values.target.id,
deviceTemplateId: targetParams.deviceTemplateId,
},
},
],
})
stopSubmit()
} catch (err) {
setErrorMessage(err as ApiError)
}
}
stopSubmit()
}, [values, setErrorMessage, stopSubmit])
}, [
values,
setErrorMessage,
stopSubmit,
closeModal,
goToStatus,
openModal,
setModalData,
])
//#endregion
return (
@ -581,12 +793,13 @@ export default React.memo<{
}
onClick={onCentralDropDownOpen}
onChange={(_, val) => {
setCentralTargetService({
setCentralSourceService({
id: val?.data.properties
.subdomain,
name: val?.data.name,
})
setDeviceGroups([])
setDeviceTemplates([])
// setDeviceGroups([])
// setDeviceTemplates([])
}}
/>
</div>
@ -617,7 +830,7 @@ export default React.memo<{
groupId: val?.data.id,
},
})
setDeviceTemplates([])
// setDeviceTemplates([])
}}
/>
</div>
@ -641,7 +854,9 @@ export default React.memo<{
!values.source.id || submitting
}
onChange={(_, val) =>
onDeviceTemplateSelected(val?.data)
onSourceDeviceTemplateSelected(
val?.data
)
}
/>
</div>
@ -675,6 +890,7 @@ export default React.memo<{
target: {
type: ServiceType.DPS,
id: val?.key as string,
name: val?.data.name,
params: {
...cur.target.params,
idScope:
@ -699,32 +915,35 @@ export default React.memo<{
id={centralAppsId2}
options={centralApps}
onRenderPlaceholder={
onCentralAppLoading
onCentralAppLoading2
}
onClick={onCentralDropDownOpen}
onClick={onCentralDropDownOpen2}
onChange={(_, val) => {
setCentralTargetService({
id: val?.data.properties
.subdomain,
name: val?.data.name,
})
}}
disabled={submitting}
/>
</div>
<div className='formInput'>
<Label htmlFor={deviceTemplateId}>
<Label htmlFor={deviceTemplateId2}>
Assign to template (optional)
</Label>
<Dropdown
id={deviceTemplateId}
options={deviceTemplates}
onRenderPlaceholder={onTemplatesLoading}
id={deviceTemplateId2}
options={deviceTemplates2}
onRenderPlaceholder={
onTemplatesLoading2
}
onClick={async () => {
if (
values.target.id &&
!submitting
) {
await onDeviceTemplateDropDownOpen()
await onDeviceTemplateDropDownOpen2()
}
}}
disabled={
@ -734,7 +953,10 @@ export default React.memo<{
setCentralTargetService({
params: {
deviceTemplateId:
val?.data['@id'],
val?.data
.capabilityModel[
'@id'
],
},
})
}}
@ -760,22 +982,6 @@ export default React.memo<{
}}
className='spaced-right'
/>
<PrimaryButton
text='Test'
onClick={async () => {
const keys = await getDPSKeys(
'/subscriptions/2efa8bb6-25bf-4895-ba64-33806dd00780/resourceGroups/paas/providers/Microsoft.Devices/provisioningServices/migratordps',
'provisioningserviceowner'
)
const sasToken = generateSaSToken(
'migratordps.azure-devices-provisioning.net',
keys.primaryKey,
'provisioningserviceowner'
)
console.log(sasToken)
}}
className='spaced-right'
/>
{submitting && (
<Spinner
size={SpinnerSize.medium}
@ -783,9 +989,12 @@ export default React.memo<{
/>
)}
</div>
<Modal isOpen={modalOpen}>
<Modal
isOpen={modalOpen}
onDismissed={async () => await modalData?.onDismiss()}
>
<div className='flex vertical spaced-left spaced-right padding1'>
{modalData}
{modalData?.content}
</div>
</Modal>
</div>

Просмотреть файл

@ -56,6 +56,7 @@ export type DPSTargetParams = {
export interface ServiceBase {
id: string
name: string
}
export interface CentralSourceService extends ServiceBase {
@ -83,14 +84,17 @@ export type TargetService = CentralTargetService | DPSTargetService
export type CommandPayload = {
idScope: string
dpsName: string
dpsId: string
dpsName?: string
dpsId?: string
centralAppName?: string
centralAppSubdomain?: string
deviceTemplateId?: string
}
export type EnrollmentGroup = {
primaryKey: string
secondaryKey: string
idScope?: string
idScope: string
}
export type JobPayload = {
@ -118,6 +122,12 @@ export type JobResult = {
path: string
value: CommandPayload
}[]
progress: {
total: number
completed: number
failed: number
pending: number
}
status: string
appName?: string
jobLink?: string
@ -131,7 +141,7 @@ export enum HubJobStatus {
}
export type HubJob = {
id:string,
id: string
hubName: string
hubHost: string
appHost: string