зеркало из https://github.com/Azure/iotc-migrator.git
refactor
This commit is contained in:
Родитель
ea0efeac75
Коммит
da500d2897
|
@ -21,3 +21,7 @@
|
|||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
|
||||
# env
|
||||
.env
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
}
|
102
README.md
102
README.md
|
@ -1,76 +1,70 @@
|
|||
# Iotc-migrator
|
||||
# Getting Started with Create React App
|
||||
|
||||
A Companion Experience that enables you to move devices between Azure IoT Central applications or move devices from an Azure IoT Central application to an Azure IoT Hub.
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Requirement
|
||||
## Available Scripts
|
||||
|
||||
1. An IoT Central Application
|
||||
In the project directory, you can run:
|
||||
|
||||
> Go to [IoT Central](https://apps.azureiotcentral.com/home) to create one.
|
||||
### `npm start`
|
||||
|
||||
2. Azure Active Directory Application (AAD).
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
> Go to [Azure Portal > AAD > App registration](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new AAD Application and follow the [instruction below](#create-an-aad-application).
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
3. An Azure IoT Hub instance
|
||||
### `npm test`
|
||||
|
||||
> Go to [Azure Portal > IoT Hub](https://portal.azure.com/#create/Microsoft.IotHub) to create one IoT Hub.
|
||||
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.
|
||||
|
||||
4. An Azure IoT Hub Device Provisioning Services (DPS) associated to the IoT Hub instance
|
||||
### `npm run build`
|
||||
|
||||
> Go to [Azure Portal > DPS](https://portal.azure.com/#create/Microsoft.IoTDeviceProvisioning) to create one DPS.
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
## Setup
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
Update the [config.ts](./src/config.ts) using the information from your AAD application.
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
```typescript
|
||||
{
|
||||
AADLoginServer: 'https://login.microsoftonline.com',
|
||||
AADClientID: '<your-AAD-Application-(client)-ID>',
|
||||
AADDirectoryID: '<your-AAD-Directory-(tenant)-ID>',
|
||||
AADRedirectURI: 'http://localhost:3000',
|
||||
applicationHost: '<your-iot-central-app>.azureiotcentral.com'
|
||||
}
|
||||
```
|
||||
### `npm run eject`
|
||||
|
||||
> Make sure that the `AADRedirectURI` in the config file and the `Redirect URIs` specified in your AAD Application are the same.
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
## First run
|
||||
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.
|
||||
|
||||
You can run the SPA in the development mode using:
|
||||
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.
|
||||
|
||||
`npm start`
|
||||
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.
|
||||
|
||||
Open in the browser the `AADRedirectURI url` previously defined in your `config.ts` (by default is [http://localhost:3000](http://localhost:3000)) to view it in the browser.
|
||||
|
||||
Once the Application is loaded, follow the guidelines in the UI to perform a migration from the IoT Central application to Azure IoT Hub.
|
||||
|
||||
> NOTE: To perform successfully the migration, make sure that the _device template_ associated to your devices has a Command capability named __DeviceMove__.
|
||||
|
||||
### Create an AAD Application
|
||||
|
||||
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 `config.ts` under _AADRedirectURI_
|
||||
![Create app](/assets/newApp.png)
|
||||
|
||||
4. Click _Register_ and once the app is created you will get the paramaters needed in the `config.ts`
|
||||
![App created](/assets/appCreated.png)
|
||||
|
||||
### Codebase
|
||||
|
||||
Iotc-migrator is a React SPA application written in Typescript that runs 100% in the browser. It was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
The project consume the [IoT Central Rest APIs](https://docs.microsoft.com/rest/api/iotcentral/) with the following version: _1.1-preview_.
|
||||
|
||||
The Authentication is performed using [Microsoft Authentication Library (MSAL)](https://www.npmjs.com/package/msal).
|
||||
|
||||
### Learn More
|
||||
## Learn More
|
||||
|
||||
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/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
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)
|
||||
|
||||
### 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)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
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)
|
||||
|
||||
### 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)
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
66
package.json
66
package.json
|
@ -1,52 +1,17 @@
|
|||
{
|
||||
"name": "iotc-migrator",
|
||||
"version": "1.0.0",
|
||||
"name": "iotc-migrator-v2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "A Companion Experience that enables you to move devices between Azure IoT Central applications or move devices from an Azure IoT Central application to an Azure IoT Hub",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"author": "Azure IoT",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^2.23.0",
|
||||
"@fluentui/react": "^8.64.1",
|
||||
"@microsoft/azure-iot-ux-fluent-controls": "^8.0.8",
|
||||
"@microsoft/azure-iot-ux-fluent-css": "^8.1.8",
|
||||
"axios": "^0.26.1",
|
||||
"react": "16.x",
|
||||
"react-dom": "16.x",
|
||||
"react-hook-form": "^7.29.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"util": "^0.12.4",
|
||||
"uuidv4": "^6.2.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"node-sass": "^7.0.1",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^12.1.4",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
||||
"@typescript-eslint/parser": "^5.19.0",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-etc": "^2.0.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.2.3",
|
||||
"@azure/msal-browser": "^2.26.0",
|
||||
"@fluentui/react": "^8.77.0",
|
||||
"@fluentui/react-hooks": "^8.6.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"uuid": "^8.3.2",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "16.x",
|
||||
"react-dom": "16.x"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
|
@ -70,5 +35,18 @@
|
|||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^6.3.1",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"eslint": "^8.21.0",
|
||||
"prettier": "^2.7.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@svgr/webpack": "$@svgr/webpack"
|
||||
}
|
||||
}
|
||||
|
|
Двоичные данные
public/favicon.ico
Двоичные данные
public/favicon.ico
Двоичный файл не отображается.
До Ширина: | Высота: | Размер: 15 KiB После Ширина: | Высота: | Размер: 3.8 KiB |
|
@ -1,15 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A Companion Experience that enables you to move devices between Azure IoT Central applications or move devices from an Azure IoT Central application to an Azure IoT Hub"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png">
|
||||
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
|
@ -24,7 +24,7 @@
|
|||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>IoTC Migrator</title>
|
||||
<title>IoT Central device migration tool</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 5.2 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 9.4 KiB |
|
@ -1,11 +1,21 @@
|
|||
{
|
||||
"short_name": "iotc migrator",
|
||||
"name": "iotc migrator",
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
height: 3rem;
|
||||
background-color: #3c3c41;
|
||||
color: white;
|
||||
display: flex;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signOut {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
background-color: #f3f2f1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formHeader {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.formInput {
|
||||
width: 400px;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.spaced-right {
|
||||
margin-right: 2em;
|
||||
}
|
||||
|
||||
.spaced-left {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.center-vertical {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.center-horizontal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 2em;
|
||||
border: 1px solid gray;
|
||||
width: fit-content;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.section > label {
|
||||
margin-top: -3.5em;
|
||||
text-align: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.migration-arrow {
|
||||
/* transform: scale(-2, -4) translate(0, -0.5em); */
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.migration-arrow-disabled {
|
||||
/* transform: scale(-2, -4) translate(0, -0.5em); */
|
||||
font-size: x-large;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.bottom-margin {
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
|
||||
.padding1 {
|
||||
padding: 1em;
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
import { AccountInfo } from '@azure/msal-browser'
|
||||
import {
|
||||
IButtonStyles,
|
||||
Icon,
|
||||
IconButton,
|
||||
Modal,
|
||||
ProgressIndicator,
|
||||
} from '@fluentui/react'
|
||||
import { useBoolean } from '@fluentui/react-hooks'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { 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'
|
||||
|
||||
const iconButtonStyles: Partial<IButtonStyles> = {
|
||||
root: {
|
||||
color: 'black',
|
||||
marginLeft: 'auto',
|
||||
marginTop: '4px',
|
||||
marginRight: '2px',
|
||||
},
|
||||
rootHovered: {
|
||||
color: 'black',
|
||||
},
|
||||
}
|
||||
export default function App() {
|
||||
const [expanded, { toggle: toggleCollapse }] = useBoolean(true)
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null)
|
||||
const [pageShown, setPageShown] = useState<'new' | 'status'>('new')
|
||||
const [modalData, setModalData] = useState<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
}>({ isOpen: false, title: '', message: '' })
|
||||
|
||||
const loginToAzure = React.useCallback(async () => {
|
||||
let auth = await loginSilent()
|
||||
if (!auth) {
|
||||
auth = (await login()).account
|
||||
}
|
||||
setAccount(auth)
|
||||
}, [])
|
||||
|
||||
const setErrorMessage = useCallback((err: ApiError) => {
|
||||
setModalData({ isOpen: true, message: err.message, title: err.title })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!account) {
|
||||
loginToAzure()
|
||||
}
|
||||
}, [loginToAzure, account])
|
||||
|
||||
return (
|
||||
<div className='container'>
|
||||
<div className='masthead'>
|
||||
<h4>Azure IoT Device Migrator</h4>
|
||||
<div className='center-vertical'>
|
||||
{account && (
|
||||
<>
|
||||
<span className='spaced-right'>{account.name}</span>
|
||||
<Icon
|
||||
className='signOut'
|
||||
iconName='SignOut'
|
||||
onClick={async () => {
|
||||
await logout()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='content'>
|
||||
<nav className={`nav`}>
|
||||
<Navigator
|
||||
expanded={expanded}
|
||||
onItemSelected={(key) => {
|
||||
setPageShown(key as any)
|
||||
}}
|
||||
>
|
||||
<NavigatorItem
|
||||
key='collapse'
|
||||
title=''
|
||||
icon={{ iconName: 'GlobalNavButton' }}
|
||||
onClick={() => {
|
||||
toggleCollapse()
|
||||
}}
|
||||
/>
|
||||
<NavigatorItem
|
||||
key='new'
|
||||
title='New migration'
|
||||
icon={{ iconName: 'TurnRight' }}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<NavigatorItem
|
||||
key='status'
|
||||
title='Migration status'
|
||||
icon={{ iconName: 'Sync' }}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Navigator>
|
||||
</nav>
|
||||
{account ? (
|
||||
pageShown === 'new' ? (
|
||||
<NewMigration setErrorMessage={setErrorMessage} />
|
||||
) : (
|
||||
<MigrationStatus setErrorMessage={setErrorMessage} />
|
||||
)
|
||||
) : (
|
||||
<div className='page'>
|
||||
<h3>Please wait</h3>
|
||||
<ProgressIndicator label='Waiting for authentication' />
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={modalData.isOpen}
|
||||
onDismiss={() => {
|
||||
setModalData((cur) => ({ ...cur, isOpen: false }))
|
||||
}}
|
||||
>
|
||||
<div className='flex vertical spaced-left spaced-right padding1'>
|
||||
<div className='center-vertical'>
|
||||
<h3 className=''>{modalData.title}</h3>
|
||||
<IconButton
|
||||
iconProps={{ iconName: 'Cancel' }}
|
||||
styles={iconButtonStyles}
|
||||
ariaLabel='Close popup modal'
|
||||
onClick={() =>
|
||||
setModalData((cur) => ({
|
||||
...cur,
|
||||
isOpen: false,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span>{modalData.message}</span>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,359 @@
|
|||
import {
|
||||
AccountInfo,
|
||||
LogLevel,
|
||||
PublicClientApplication,
|
||||
} from '@azure/msal-browser'
|
||||
import {
|
||||
API_VERSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
ApiError,
|
||||
JOB_DESCRIPTION,
|
||||
JobResult,
|
||||
JobPayload,
|
||||
} from './types'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: process.env['REACT_APP_AAD_APP_CLIENT_ID'] || '',
|
||||
authority: `https://login.microsoftonline.com/${process.env['REACT_APP_AAD_APP_TENANT_ID']}`,
|
||||
redirectUri: process.env['REACT_APP_AAD_APP_REDIRECT_URI'] || '',
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
containsPii: boolean
|
||||
) => {
|
||||
if (containsPii) {
|
||||
return
|
||||
}
|
||||
switch (level) {
|
||||
case LogLevel.Error:
|
||||
console.error(message)
|
||||
return
|
||||
case LogLevel.Info:
|
||||
console.info(message)
|
||||
return
|
||||
case LogLevel.Verbose:
|
||||
console.debug(message)
|
||||
return
|
||||
case LogLevel.Warning:
|
||||
console.warn(message)
|
||||
return
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const msalInstance = new PublicClientApplication(msalConfig)
|
||||
const basicAuthParameters = {
|
||||
scopes: ['user.read'],
|
||||
extraScopesToConsent: ['https://management.azure.com/user_impersonation'],
|
||||
}
|
||||
|
||||
let account: AccountInfo | null
|
||||
|
||||
function getAccount(): AccountInfo | null {
|
||||
// need to call getAccount here?
|
||||
const cache = msalInstance.getTokenCache()
|
||||
const currentAccounts = msalInstance.getAllAccounts()
|
||||
|
||||
if (currentAccounts === null) {
|
||||
console.log('No accounts detected')
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentAccounts.length > 1) {
|
||||
// Add choose account code here
|
||||
console.log(
|
||||
'Multiple accounts detected, need to add choose account code.'
|
||||
)
|
||||
return currentAccounts[0]
|
||||
} else if (currentAccounts.length === 1) {
|
||||
return currentAccounts[0]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginSilent(): Promise<AccountInfo | null> {
|
||||
if (!account) {
|
||||
account = getAccount()
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
export async function login(resource?: string) {
|
||||
const auth = await msalInstance.loginPopup(
|
||||
resource ? { scopes: [resource] } : basicAuthParameters
|
||||
)
|
||||
if (auth && auth.account) {
|
||||
account = auth.account
|
||||
} else {
|
||||
account = getAccount()
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await msalInstance.logoutPopup({
|
||||
postLogoutRedirectUri: process.env['REACT_APP_AAD_APP_REDIRECT_URI'],
|
||||
mainWindowRedirectUri: process.env['REACT_APP_AAD_APP_REDIRECT_URI'],
|
||||
})
|
||||
}
|
||||
|
||||
export async function getToken(resource: string) {
|
||||
try {
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
const authResult = await msalInstance.acquireTokenSilent({
|
||||
scopes: [resource],
|
||||
account,
|
||||
})
|
||||
return authResult.accessToken
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return (await login(resource)).accessToken
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHubKeys(hubId: string, policyName: string) {
|
||||
const armToken = await getToken(
|
||||
'https://management.azure.com/user_impersonation'
|
||||
)
|
||||
const params = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${armToken}`,
|
||||
},
|
||||
}
|
||||
|
||||
const hubResp = await fetch(
|
||||
`https://management.azure.com${hubId}/IoTHubKeys/${policyName}/listkeys?api-version=${API_VERSIONS.IoTHubArm}`,
|
||||
params
|
||||
)
|
||||
return hubResp.json()
|
||||
}
|
||||
|
||||
export async function listDPSs() {
|
||||
const armToken = await getToken(
|
||||
'https://management.azure.com/user_impersonation'
|
||||
)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${armToken}`,
|
||||
},
|
||||
}
|
||||
const subResp = await fetch(
|
||||
`https://management.azure.com/subscriptions?api-version=${API_VERSIONS.ResourceManager}`,
|
||||
params
|
||||
)
|
||||
const subs = (await subResp.json()).value
|
||||
const resources = await Promise.all(
|
||||
subs.map(async (sub: any) => {
|
||||
const resResp = await fetch(
|
||||
`https://management.azure.com/subscriptions/${sub.subscriptionId}/resources?api-version=${API_VERSIONS.ResourceManager}&$filter=resourceType eq 'Microsoft.Devices/provisioningServices'`,
|
||||
params
|
||||
)
|
||||
const resources = (await resResp.json()).value
|
||||
return resources.map((r: any) => ({
|
||||
...r,
|
||||
dpsLink: `https://portal.azure.com/#@${sub.tenantId}/resource${r.id}`,
|
||||
}))
|
||||
})
|
||||
)
|
||||
const data = await Promise.all(
|
||||
resources.flat().map(async (res: any) => {
|
||||
const resResp = await fetch(
|
||||
`https://management.azure.com${res.id}?api-version=${API_VERSIONS.DPS}`,
|
||||
params
|
||||
)
|
||||
const data = await resResp.json()
|
||||
return { ...data, dpsLink: res.dpsLink }
|
||||
})
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listHubs() {
|
||||
const armToken = await getToken(
|
||||
'https://management.azure.com/user_impersonation'
|
||||
)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${armToken}`,
|
||||
},
|
||||
}
|
||||
const subResp = await fetch(
|
||||
`https://management.azure.com/subscriptions?api-version=${API_VERSIONS.ResourceManager}`,
|
||||
params
|
||||
)
|
||||
const subs = (await subResp.json()).value
|
||||
const resources = await Promise.all(
|
||||
subs.map(async (sub: any) => {
|
||||
const resResp = await fetch(
|
||||
`https://management.azure.com/subscriptions/${sub.subscriptionId}/resources?api-version=${API_VERSIONS.ResourceManager}&$filter=resourceType eq 'Microsoft.Devices/IotHubs'`,
|
||||
params
|
||||
)
|
||||
const resources = (await resResp.json()).value
|
||||
return resources.map((r: any) => ({
|
||||
...r,
|
||||
hubLink: `https://portal.azure.com/#@${sub.tenantId}/resource${r.id}`,
|
||||
}))
|
||||
})
|
||||
)
|
||||
const data = await Promise.all(
|
||||
resources.flat().map(async (res: any) => {
|
||||
const resResp = await fetch(
|
||||
`https://management.azure.com${res.id}?api-version=${API_VERSIONS.IoTHubArm}`,
|
||||
params
|
||||
)
|
||||
const data = await resResp.json()
|
||||
return { ...data, hubLink: res.hubLink }
|
||||
})
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listDevicesInHub(
|
||||
hubHost: string,
|
||||
sasToken: string
|
||||
): Promise<any[]> {
|
||||
const params = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: sasToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: 'select * from devices',
|
||||
}),
|
||||
}
|
||||
const listResp = await fetch(
|
||||
`https://${hubHost}/devices/query?api-version=${API_VERSIONS.IoTHubData}`,
|
||||
params
|
||||
)
|
||||
return listResp.json()
|
||||
}
|
||||
|
||||
export async function listCentralApps() {
|
||||
const armToken = await getToken(
|
||||
'https://management.azure.com/user_impersonation'
|
||||
)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${armToken}`,
|
||||
},
|
||||
}
|
||||
const subResp = await fetch(
|
||||
`https://management.azure.com/subscriptions?api-version=${API_VERSIONS.ResourceManager}`,
|
||||
params
|
||||
)
|
||||
const subs = (await subResp.json()).value
|
||||
const resources = await Promise.all(
|
||||
subs.map(async (sub: any) => {
|
||||
const resResp = await fetch(
|
||||
`https://management.azure.com/subscriptions/${sub.subscriptionId}/providers/Microsoft.IoTCentral/iotApps?api-version=2021-06-01`,
|
||||
params
|
||||
)
|
||||
const resources = (await resResp.json()).value
|
||||
return resources
|
||||
})
|
||||
)
|
||||
return resources.flat()
|
||||
}
|
||||
|
||||
export async function listDeviceGroups(appDomain: string) {
|
||||
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${centralToken}`,
|
||||
},
|
||||
}
|
||||
const groups = await fetch(
|
||||
`https://${appDomain}.azureiotcentral.com/api/deviceGroups?api-version=${API_VERSIONS.Central}`,
|
||||
params
|
||||
)
|
||||
return (await groups.json()).value
|
||||
}
|
||||
|
||||
export async function listJobs(appDomain: string) {
|
||||
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${centralToken}`,
|
||||
},
|
||||
}
|
||||
const jobs = await fetch(
|
||||
`https://${appDomain}.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) {
|
||||
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
|
||||
const params = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${centralToken}`,
|
||||
},
|
||||
}
|
||||
const templates = await fetch(
|
||||
`https://${appDomain}.azureiotcentral.com/api/deviceTemplates?api-version=${API_VERSIONS.Central}`,
|
||||
params
|
||||
)
|
||||
return (await templates.json()).value
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param appDomain
|
||||
* @param migrationData
|
||||
* @returns migrationStatus
|
||||
* @throws ApiError
|
||||
*/
|
||||
export async function createMigrationJob(
|
||||
appDomain: string,
|
||||
migrationData: JobPayload
|
||||
) {
|
||||
const centralToken = await getToken(TOKEN_AUDIENCES.Central)
|
||||
const params = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(migrationData),
|
||||
headers: {
|
||||
Authorization: `Bearer ${centralToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
const job = await fetch(
|
||||
`https://${appDomain}.azureiotcentral.com/api/jobs/${uuid()}?api-version=${
|
||||
API_VERSIONS.Central
|
||||
}`,
|
||||
params
|
||||
)
|
||||
if (!job.ok) {
|
||||
const errbody = await job.json()
|
||||
let message = errbody.error.message
|
||||
if (job.status === 422) {
|
||||
//bad template
|
||||
message = `${message}\n.Make sure you have the "DeviceMigration" component in your model definition.\nCheck documentation for instructions.`
|
||||
}
|
||||
throw new ApiError(errbody.error.code, message)
|
||||
}
|
||||
return job.json()
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
|
||||
export const Config = {
|
||||
AADLoginServer: 'https://login.microsoftonline.com',
|
||||
AADClientID: '<your-AAD-Application-(client)-ID>',
|
||||
AADDirectoryID: '<your-AAD-Directory-(tenant)-ID>',
|
||||
AADRedirectURI: 'http://localhost:3000',
|
||||
applicationHost: '<your-iot-central-app>.azureiotcentral.com'
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as IotcAPI from '../iotcAPI';
|
||||
import { AuthContextInterface } from './authContext';
|
||||
|
||||
|
||||
export interface AppDataContextInterface {
|
||||
appDataReady: boolean;
|
||||
groups: {key: string, text: string}[];
|
||||
templates: {key: string, text: string}[];
|
||||
fetchAppData: (authContext: AuthContextInterface) => Promise<void>;
|
||||
error: any;
|
||||
}
|
||||
|
||||
export const AppDataContext = React.createContext<AppDataContextInterface>({} as AppDataContextInterface);
|
||||
|
||||
export function AppDataProvider({ children }: { children: any }) {
|
||||
|
||||
// turns the results array into an associative array with the id as key. Used for look ups
|
||||
const getDeviceGroups = async (authContext: AuthContextInterface) => {
|
||||
const res = await IotcAPI.getGroups(authContext);
|
||||
return res.map(x => {
|
||||
return {
|
||||
key: x.id,
|
||||
text: x.displayName
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// turns the results array into an associative array with the id as key. Used for look ups
|
||||
const getTemplates = async (authContext: AuthContextInterface) => {
|
||||
const res = await IotcAPI.getTemplates(authContext);
|
||||
return res.map(x => {
|
||||
return {
|
||||
key: x.id,
|
||||
text: x.displayName
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// this works because its single app
|
||||
const fetchAppData = async (authContext: AuthContextInterface) => {
|
||||
try {
|
||||
const groups = await getDeviceGroups(authContext);
|
||||
const templates = await getTemplates(authContext);
|
||||
setAppState({
|
||||
...appState,
|
||||
groups: groups || [],
|
||||
templates: templates || [],
|
||||
appDataReady: true,
|
||||
error: undefined
|
||||
});
|
||||
} catch (error) {
|
||||
setAppState({
|
||||
...appState,
|
||||
appDataReady: false,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [appState, setAppState] = React.useState<AppDataContextInterface>({
|
||||
appDataReady: false,
|
||||
groups: [],
|
||||
templates: [],
|
||||
fetchAppData,
|
||||
error: undefined
|
||||
});
|
||||
|
||||
return (
|
||||
<AppDataContext.Provider value={appState}>
|
||||
{children}
|
||||
</AppDataContext.Provider>
|
||||
)
|
||||
}
|
|
@ -1,165 +0,0 @@
|
|||
import * as msal from '@azure/msal-browser';
|
||||
import * as React from 'react';
|
||||
import { Config } from '../config';
|
||||
|
||||
export interface AppConfig {
|
||||
applicationId: string;
|
||||
directoryId: string;
|
||||
applicationHost: string;
|
||||
}
|
||||
|
||||
export const Scopes = {
|
||||
Graph: 'User.Read',
|
||||
Central: 'https://apps.azureiotcentral.com/user_impersonation',
|
||||
ARM: 'https://management.azure.com/user_impersonation'
|
||||
}
|
||||
|
||||
export interface AuthContextInterface {
|
||||
authenticated: boolean;
|
||||
applicationHost: string;
|
||||
loginAccount: msal.AccountInfo | undefined;
|
||||
signIn: (silent: boolean) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
getAccessToken: (authContext?: msal.AccountInfo, scope?: string) => Promise<string>;
|
||||
error: any;
|
||||
}
|
||||
|
||||
export const AuthContext = React.createContext<AuthContextInterface>({} as AuthContextInterface);
|
||||
|
||||
export function AuthProvider({ children }: { children: any }) {
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const applicationHost = urlParams?.get?.('appHost');
|
||||
|
||||
const msalConfig = React.useMemo(() => {
|
||||
return {
|
||||
auth: {
|
||||
clientId: Config.AADClientID,
|
||||
authority: `${Config.AADLoginServer}/${Config.AADDirectoryID}`,
|
||||
redirectUri: Config.AADRedirectURI
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'localStorage'
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const msalInstance = new msal.PublicClientApplication(msalConfig);
|
||||
|
||||
async function signIn(silent = false) {
|
||||
if (state.authenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(Config.AADClientID === '<your-AAD-client-id>'){
|
||||
setState({
|
||||
...state,
|
||||
loginAccount: undefined,
|
||||
authenticated: true,
|
||||
error: {
|
||||
message: 'Please update the config object in src/config.ts'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let loginAccount: msal.AuthenticationResult = {} as msal.AuthenticationResult;
|
||||
|
||||
// @returns Token response or null. If the return value is null, then no auth redirect was detected.
|
||||
let res = await msalInstance.handleRedirectPromise();
|
||||
|
||||
try {
|
||||
loginAccount = res
|
||||
? (res as any).data.value[0]
|
||||
: msalInstance.getAllAccounts()[0];
|
||||
|
||||
try {
|
||||
res = await getAccessTokenForScope({ silentFail: silent, msalInstance, scope: Scopes.Graph, options: loginAccount ? { account: loginAccount } : null });
|
||||
} catch (error) {
|
||||
//swallow error and try with a IoTCentral scope
|
||||
try {
|
||||
res = await getAccessTokenForScope({ silentFail: silent, msalInstance, scope: Scopes.Central, options: loginAccount ? { account: loginAccount } : null });
|
||||
} catch (error) {
|
||||
//swallow error and try with a ARM scope
|
||||
res = await getAccessTokenForScope({ silentFail: silent, msalInstance, scope: Scopes.ARM, options: loginAccount ? { account: loginAccount } : null });
|
||||
}
|
||||
}
|
||||
|
||||
// at this point we should have a token otherwise the catch will have thrown an error on the modal
|
||||
|
||||
msalInstance.setActiveAccount(res?.account as msal.AccountInfo);
|
||||
|
||||
setState({
|
||||
...state,
|
||||
loginAccount: res?.account as msal.AccountInfo,
|
||||
authenticated: true,
|
||||
error: undefined
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setState({
|
||||
...state,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccessToken(authContext?: msal.AccountInfo | undefined, scope?: string) {
|
||||
const res = await getAccessTokenForScope({ silentFail: true, msalInstance, scope: scope || Scopes.Central, options: { account: state.loginAccount || authContext } });
|
||||
return res?.accessToken;
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await msalInstance.logoutRedirect();
|
||||
}
|
||||
|
||||
const [state, setState] = React.useState<AuthContextInterface>({
|
||||
authenticated: false,
|
||||
applicationHost: applicationHost || Config.applicationHost,
|
||||
loginAccount: undefined,
|
||||
signIn,
|
||||
signOut,
|
||||
getAccessToken,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface AccessTokenForScope {
|
||||
silentFail: boolean;
|
||||
msalInstance: msal.PublicClientApplication;
|
||||
scope: string;
|
||||
options: any;
|
||||
}
|
||||
|
||||
async function getAccessTokenForScope({ msalInstance, scope, options }: AccessTokenForScope): Promise<msal.AuthenticationResult | null> {
|
||||
const tokenRequest = {
|
||||
scopes: Array.isArray(scope) ? scope : [scope],
|
||||
forceRefresh: false,
|
||||
redirectUri: Config.AADRedirectURI,
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
// try to get token silently if the user is already signed in
|
||||
return await msalInstance.acquireTokenSilent(tokenRequest);
|
||||
} catch (err) {
|
||||
console.log('login error', err);
|
||||
try {
|
||||
// show the login popup if the user is not signed in
|
||||
return await msalInstance.acquireTokenPopup(tokenRequest)
|
||||
} catch (error: any) {
|
||||
console.log('login error', error);
|
||||
if (error.name === 'BrowserAuthError') {
|
||||
return await msalInstance.acquireTokenPopup(tokenRequest);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import Modal from './modal';
|
||||
|
||||
interface ErrorBoundaryProperties {
|
||||
children?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* A custom render function to display the service error.
|
||||
* If not specified, defaults to `ErrorView`.
|
||||
*/
|
||||
render?: (err: any) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Callback to do something when the error is caught.
|
||||
*/
|
||||
onDidCatch?: (err: any) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error?: any;
|
||||
location?: string; // location href where the error occurs
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to catch errors in a child component tree, log them, and display a fallback UI.
|
||||
* https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProperties, State> {
|
||||
constructor(props: ErrorBoundaryProperties) {
|
||||
super(props);
|
||||
this.state = { error: undefined };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error, location: getLocation() };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.onDidCatch?.(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, location } = this.state;
|
||||
|
||||
if (error && getLocation() === location) {
|
||||
const { render } = this.props;
|
||||
return render ? render(error) : <Modal error={error} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
return typeof window !== 'undefined' ? window.location.href : '';
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.modal-content {
|
||||
width: 600px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
flex-grow: 1;
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames/bind';
|
||||
|
||||
import { IIconProps } from '@fluentui/react/lib/Icon';
|
||||
import { IconButton, Modal as FluentModal } from '@fluentui/react';
|
||||
const cx = classnames.bind(require('./modal.scss'));
|
||||
|
||||
export default React.memo(function Modal({ error, closeModal }: {
|
||||
error: any;
|
||||
closeModal?: () => void;
|
||||
}): JSX.Element {
|
||||
|
||||
const cancelIcon: IIconProps = { iconName: 'Cancel' };
|
||||
|
||||
|
||||
return <FluentModal
|
||||
titleAriaId={'Error Modal'}
|
||||
isOpen={!!error.title}
|
||||
onDismiss={closeModal}
|
||||
isBlocking={true}
|
||||
>
|
||||
<div className={cx('modal-content')}>
|
||||
<div className={cx('modal-title-container')}>
|
||||
<h3 className={cx('modal-title')} id={'Error Modal'}>{error.title}</h3>
|
||||
{closeModal && <IconButton
|
||||
iconProps={cancelIcon}
|
||||
ariaLabel="Close popup modal"
|
||||
onClick={closeModal}
|
||||
/>}
|
||||
</div>
|
||||
<div className={cx('modal-message')}>
|
||||
{error.message}
|
||||
<br />
|
||||
<span>Check the browser console for more details or refresh the page.</span>
|
||||
</div>
|
||||
</div>
|
||||
</FluentModal>;
|
||||
|
||||
});
|
|
@ -1,24 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
const usePromise = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<any>(null);
|
||||
const [error, setError] = React.useState<any>(null);
|
||||
|
||||
const callPromise = async ({ promiseFn }: { promiseFn: any }) => {
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await promiseFn();
|
||||
setData(res);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return [loading, data, error, callPromise];
|
||||
};
|
||||
|
||||
export default usePromise
|
|
@ -0,0 +1,16 @@
|
|||
html,body,#root{
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Frutiger", "Frutiger Linotype", "Dejavu Sans",
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { initializeIcons } from '@fluentui/font-icons-mdl2';
|
||||
|
||||
initializeIcons();
|
||||
|
||||
const root = ReactDOM.render(
|
||||
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
, document.getElementById('root'));
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
|
@ -1,15 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Roboto', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 600 !important;
|
||||
font-size: 1.7rem !important;
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import './index.scss';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
|
||||
import Shell from './shell/shell';
|
||||
import { AuthProvider } from './context/authContext';
|
||||
import { AppDataProvider } from './context/appDataContext';
|
||||
|
||||
import { initializeIcons } from '@fluentui/react/lib/Icons';
|
||||
import { ErrorBoundary } from './controls/errorBoundary';
|
||||
|
||||
initializeIcons();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppDataProvider>
|
||||
<ErrorBoundary>
|
||||
<Shell />
|
||||
</ErrorBoundary>
|
||||
</AppDataProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
207
src/iotcAPI.ts
207
src/iotcAPI.ts
|
@ -1,207 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuthContextInterface, Scopes } from './context/authContext';
|
||||
|
||||
interface IOTCentralResponse<T> {
|
||||
data: {
|
||||
value: T[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface DeviceGroup {
|
||||
id: string;
|
||||
displayName: string;
|
||||
organizations: string[];
|
||||
}
|
||||
|
||||
export interface DeviceTemplate {
|
||||
id: string;
|
||||
types: string[];
|
||||
capabilityModel: Object;
|
||||
solutionModel: Object;
|
||||
displayName: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface Job { }
|
||||
|
||||
export interface DPS {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export interface AppSubs {
|
||||
subscriptionId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export async function getGroups(authContext: AuthContextInterface): Promise<DeviceGroup[]> {
|
||||
try {
|
||||
const token = await authContext.getAccessToken(authContext.loginAccount, Scopes.Central);
|
||||
const res: IOTCentralResponse<DeviceGroup> = await axios(`https://${authContext.applicationHost}/api/preview/deviceGroups`, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
return res.data.value;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplates(authContext: AuthContextInterface): Promise<DeviceTemplate[]> {
|
||||
try {
|
||||
const token = await authContext.getAccessToken(authContext.loginAccount, Scopes.Central);
|
||||
const res: IOTCentralResponse<DeviceTemplate> = await axios(`https://${authContext.applicationHost}/api/preview/deviceTemplates`, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
return res.data.value;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface JobPayload {
|
||||
group: string;
|
||||
migrationName: string;
|
||||
migrationOption: 'App' | 'Hub';
|
||||
target: string;
|
||||
template: boolean;
|
||||
}
|
||||
|
||||
export async function postJob(authContext: AuthContextInterface, payload: JobPayload): Promise<Job> {
|
||||
try {
|
||||
const token = await authContext.getAccessToken(authContext.loginAccount, Scopes.Central);
|
||||
const res: IOTCentralResponse<Job> = await axios.put(`https://${authContext.applicationHost}/api/jobs/${uuidv4()}?api-version=1.1-preview`,
|
||||
{
|
||||
displayName: payload.migrationName,
|
||||
description: 'AUTOMATED-DEVICE-MOVE',
|
||||
group: payload.group,
|
||||
data: [
|
||||
{
|
||||
type: 'command',
|
||||
target: payload.template,
|
||||
path: 'DeviceMove',
|
||||
value: payload.target
|
||||
}
|
||||
]
|
||||
},
|
||||
{ headers: { 'Authorization': 'Bearer ' + token } }
|
||||
);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Row {
|
||||
key: string;
|
||||
id: string;
|
||||
name: string;
|
||||
dgroup: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getJobs(authContext: AuthContextInterface) {
|
||||
try {
|
||||
const token = await authContext.getAccessToken();
|
||||
const res = await axios(`https://${authContext.applicationHost}/api/preview/jobs`,
|
||||
{
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
|
||||
const rows: Row[] = [];
|
||||
for (const i in res.data.value) {
|
||||
const job = res.data.value[i];
|
||||
if (job.description && job.description.startsWith('AUTOMATED-DEVICE-MOVE')) {
|
||||
rows.push({
|
||||
key: i,
|
||||
id: job.id,
|
||||
name: job.displayName,
|
||||
dgroup: job.group,
|
||||
status: job.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface App {
|
||||
key: string;
|
||||
text: string;
|
||||
app: Object;
|
||||
sub: Object;
|
||||
}
|
||||
|
||||
export async function getDPS(authContext: AuthContextInterface) {
|
||||
try {
|
||||
const armToken = await authContext.getAccessToken(authContext.loginAccount, Scopes.ARM);
|
||||
let apps: App[] = [];
|
||||
const res: IOTCentralResponse<DPS> = await axios.get(`https://management.azure.com/subscriptions?api-version=2020-01-01`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + armToken }
|
||||
});
|
||||
const subs = res.data.value;
|
||||
if (!subs || subs.length === 0) {
|
||||
return apps;
|
||||
}
|
||||
for (const i in subs) {
|
||||
const sub = subs[i];
|
||||
const subscriptionResponse = await axios.get(`https://management.azure.com/subscriptions/${sub.subscriptionId}/providers/Microsoft.Devices/provisioningServices?api-version=2018-01-22`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + armToken }
|
||||
});
|
||||
|
||||
for (const i in subscriptionResponse.data.value) {
|
||||
const app = subscriptionResponse.data.value[i];
|
||||
apps.push({
|
||||
key: app.properties.idScope,
|
||||
text: app.properties.serviceOperationsHostName,
|
||||
app,
|
||||
sub
|
||||
});
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscriptionsApps(authContext: AuthContextInterface) {
|
||||
try {
|
||||
const armToken = await authContext.getAccessToken(authContext.loginAccount, Scopes.ARM);
|
||||
let apps: App[] = [];
|
||||
const response: IOTCentralResponse<AppSubs> = await axios.get(`https://management.azure.com/subscriptions?api-version=2020-01-01`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + armToken }
|
||||
});
|
||||
const subs = response.data.value;
|
||||
if (!subs || subs.length === 0) {
|
||||
return apps;
|
||||
}
|
||||
for (const i in subs) {
|
||||
const sub = subs[i];
|
||||
const subscriptionResponse = await axios.get(`https://management.azure.com/subscriptions/${sub.subscriptionId}/providers/Microsoft.IoTCentral/IoTApps?api-version=2018-09-01`,
|
||||
{
|
||||
headers: { Authorization: 'Bearer ' + armToken }
|
||||
});
|
||||
|
||||
for (const i in subscriptionResponse.data.value) {
|
||||
const app = subscriptionResponse.data.value[i];
|
||||
apps.push({
|
||||
key: app.properties.applicationId,
|
||||
text: `(${sub.displayName}) - ${app.properties.displayName}`,
|
||||
app,
|
||||
sub
|
||||
})
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
После Ширина: | Высота: | Размер: 2.6 KiB |
|
@ -0,0 +1,115 @@
|
|||
import { FontWeights, Icon, IIconProps } from '@fluentui/react'
|
||||
import { useBoolean } from '@fluentui/react-hooks'
|
||||
import React, { MouseEventHandler, useState } from 'react'
|
||||
|
||||
export interface NavigatorItemProps {
|
||||
icon?: IIconProps
|
||||
key: string
|
||||
title: string
|
||||
text?: string
|
||||
onClick: MouseEventHandler<HTMLLIElement>
|
||||
}
|
||||
|
||||
const NavigatorItemStyle = {
|
||||
li: {
|
||||
listStyleType: 'none',
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
paddingLeft: '1em',
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
selected: {
|
||||
backgroundColor: 'white',
|
||||
fontWeight: FontWeights.semibold,
|
||||
},
|
||||
icon: {
|
||||
paddingRight: 15,
|
||||
fontSize: 16,
|
||||
},
|
||||
text: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
} as React.CSSProperties,
|
||||
}
|
||||
|
||||
export const NavigatorItem: React.FC<NavigatorItemProps> = () => null
|
||||
|
||||
const NavigatorItemInternal: React.FC<
|
||||
NavigatorItemProps & { selected: boolean }
|
||||
> = ({ icon, title, onClick, selected }) => {
|
||||
const [hover, { toggle: toggleHover }] = useBoolean(false)
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
...NavigatorItemStyle.li,
|
||||
...(selected ? NavigatorItemStyle.selected : {}),
|
||||
...(hover ? { backgroundColor: '#dadada' } : {}),
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseEnter={toggleHover}
|
||||
onMouseLeave={toggleHover}
|
||||
>
|
||||
{icon && <Icon style={NavigatorItemStyle.icon} {...icon} />}
|
||||
<span style={NavigatorItemStyle.text}>{title}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const NavigatorStyle = {
|
||||
expanded: {
|
||||
width: 200,
|
||||
height: '100%',
|
||||
paddingLeft: 0,
|
||||
margin: 0,
|
||||
transition: 'width 0.2s',
|
||||
},
|
||||
collapsed: {
|
||||
width: '3rem',
|
||||
height: '100%',
|
||||
paddingLeft: 0,
|
||||
margin: 0,
|
||||
transition: 'width 0.2s',
|
||||
},
|
||||
}
|
||||
interface INavigatorProps {
|
||||
children:
|
||||
| React.ReactElement<NavigatorItemProps>
|
||||
| React.ReactElement<NavigatorItemProps>[]
|
||||
onItemSelected: (itemKey: string) => void
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
export const Navigator = ({
|
||||
children,
|
||||
onItemSelected,
|
||||
expanded = true,
|
||||
}: INavigatorProps) => {
|
||||
const [selected, setSelected] = useState(1)
|
||||
return (
|
||||
<ul style={NavigatorStyle[expanded ? 'expanded' : 'collapsed']}>
|
||||
{Array.isArray(children)
|
||||
? children.map((item, idx) => {
|
||||
const { onClick: onItemClick, ...otherProps } = item.props
|
||||
return (
|
||||
<NavigatorItemInternal
|
||||
{...otherProps}
|
||||
onClick={(e) => {
|
||||
if (idx !== 0) {
|
||||
setSelected(idx)
|
||||
}
|
||||
onItemClick(e)
|
||||
onItemSelected(item.key as any)
|
||||
}}
|
||||
selected={selected === idx}
|
||||
key={`navitem-${idx}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: children}
|
||||
</ul>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
DetailsList,
|
||||
IColumn,
|
||||
IconButton,
|
||||
SelectionMode,
|
||||
ShimmeredDetailsList,
|
||||
} from '@fluentui/react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { listCentralApps, listJobs } from '../api'
|
||||
import { ApiError, JobResult } from '../types'
|
||||
|
||||
const columns: IColumn[] = [
|
||||
{
|
||||
key: 'appname',
|
||||
name: 'Application',
|
||||
fieldName: 'appName',
|
||||
minWidth: 16,
|
||||
maxWidth: 200,
|
||||
},
|
||||
{
|
||||
key: 'jobName',
|
||||
name: 'Migration Job',
|
||||
fieldName: 'displayName',
|
||||
maxWidth: 200,
|
||||
minWidth: 16,
|
||||
},
|
||||
{
|
||||
key: 'groupName',
|
||||
name: 'DeviceGroup',
|
||||
fieldName: 'group',
|
||||
maxWidth: 200,
|
||||
minWidth: 16,
|
||||
},
|
||||
{
|
||||
key: 'dpsName',
|
||||
name: 'DPS',
|
||||
maxWidth: 200,
|
||||
minWidth: 16,
|
||||
},
|
||||
{
|
||||
key: 'jobStatus',
|
||||
name: 'Migration Status',
|
||||
fieldName: 'status',
|
||||
maxWidth: 200,
|
||||
minWidth: 16,
|
||||
},
|
||||
]
|
||||
|
||||
function _onRenderItemColumn(
|
||||
item?: JobResult,
|
||||
index?: number,
|
||||
column?: IColumn
|
||||
) {
|
||||
const fieldContent = item?.[column?.fieldName as keyof JobResult] as string
|
||||
switch (column?.key) {
|
||||
case 'jobName':
|
||||
return (
|
||||
<a href={item?.jobLink} target='_blank'>
|
||||
{fieldContent}
|
||||
</a>
|
||||
)
|
||||
case 'dpsName':
|
||||
return (
|
||||
<a href={item?.data[0].value.dpsId} target='_blank'>
|
||||
{item?.data[0].value.dpsName}
|
||||
</a>
|
||||
)
|
||||
default:
|
||||
return <span>{fieldContent}</span>
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo<{ setErrorMessage: (err: ApiError) => void }>(() => {
|
||||
const [items, setItems] = useState<JobResult[]>([])
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
const apps = await listCentralApps()
|
||||
const jobs = await Promise.all(
|
||||
apps.map(async (a) => {
|
||||
const appJobs: JobResult[] = (
|
||||
await listJobs(a.properties.subdomain)
|
||||
)
|
||||
.filter((job: JobResult) => job.id)
|
||||
.map((job: JobResult) => ({
|
||||
...job,
|
||||
appName: a.name,
|
||||
jobLink: `https://${a.properties.subdomain}.azureiotcentral.com/jobs/instances/${job.id}`,
|
||||
}))
|
||||
return appJobs
|
||||
})
|
||||
)
|
||||
setItems(jobs.flat())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='page'>
|
||||
<h2>Migration status</h2>
|
||||
<div className='formHeader center-vertical'>
|
||||
<span>Watch all the IoT Central to IoT Hub migration processes.</span>
|
||||
<IconButton
|
||||
iconProps={{ iconName: 'Sync' }}
|
||||
onClick={async () => {
|
||||
setItems([])
|
||||
await fetchJobs()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ShimmeredDetailsList
|
||||
columns={columns}
|
||||
items={items}
|
||||
enableShimmer={items.length === 0}
|
||||
selectionMode={SelectionMode.none}
|
||||
onRenderItemColumn={_onRenderItemColumn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,767 @@
|
|||
import {
|
||||
Checkbox,
|
||||
ChoiceGroup,
|
||||
Dropdown,
|
||||
Icon,
|
||||
IDropdownOption,
|
||||
Label,
|
||||
PrimaryButton,
|
||||
Spinner,
|
||||
SpinnerSize,
|
||||
Stack,
|
||||
TextField,
|
||||
} from '@fluentui/react'
|
||||
import { useId, useBoolean } from '@fluentui/react-hooks'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
listCentralApps,
|
||||
listDPSs,
|
||||
listDeviceGroups,
|
||||
listDeviceTemplates,
|
||||
createMigrationJob,
|
||||
getToken,
|
||||
getHubKeys,
|
||||
listHubs,
|
||||
listDevicesInHub,
|
||||
} from '../api'
|
||||
import {
|
||||
FormValues,
|
||||
MigrationMode,
|
||||
ServiceType,
|
||||
ApiError,
|
||||
JOB_DESCRIPTION,
|
||||
TOKEN_AUDIENCES,
|
||||
CentralSourceService,
|
||||
DPSSourceService,
|
||||
CentralSourceParams,
|
||||
DPSSourceParams,
|
||||
DPSTargetParams,
|
||||
DPSTargetService,
|
||||
RecursivePartial,
|
||||
CentralTargetService,
|
||||
} from '../types'
|
||||
import { filterHubs, findComponent, generateSaSToken } from '../utils'
|
||||
|
||||
const modeOptions = [
|
||||
{ key: MigrationMode.ToHub, text: 'IoT Central -> IoT Hub' },
|
||||
{ key: MigrationMode.Central, text: 'IoT Central -> IoT Central' },
|
||||
{ key: MigrationMode.FromHub, text: 'IoT Hub -> IoT Central' },
|
||||
]
|
||||
|
||||
function _onDataLoading(text: string, loadingInstances: boolean) {
|
||||
return (
|
||||
<Stack
|
||||
horizontal
|
||||
verticalAlign='center'
|
||||
horizontalAlign='space-between'
|
||||
>
|
||||
<span>{text}</span>
|
||||
<Spinner
|
||||
size={SpinnerSize.small}
|
||||
style={{ visibility: loadingInstances ? 'visible' : 'hidden' }}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const _onDropDownOpen = async (
|
||||
fn: () => Promise<any[]>,
|
||||
setData: (data: any) => void,
|
||||
start: () => void,
|
||||
stop: () => void
|
||||
) => {
|
||||
start()
|
||||
const data = await fn()
|
||||
|
||||
stop()
|
||||
setData(
|
||||
data?.flat().map((r, idx) => ({
|
||||
key: r.id || `opt-${idx}`,
|
||||
text: r.name || r.displayName,
|
||||
data: r,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo<{ setErrorMessage: (err: ApiError) => void }>(
|
||||
({ setErrorMessage }) => {
|
||||
const nameId = useId()
|
||||
const modeId = useId()
|
||||
const centralAppsId = useId()
|
||||
const centralAppsId2 = useId()
|
||||
const dpsId = useId()
|
||||
const deviceGroupsId = useId()
|
||||
const deviceTemplateId = useId()
|
||||
|
||||
const [values, setValues] = useState<FormValues>({
|
||||
name: '',
|
||||
mode: MigrationMode.ToHub,
|
||||
source: {
|
||||
type: ServiceType.Central,
|
||||
id: '',
|
||||
} as CentralSourceService,
|
||||
target: { type: ServiceType.DPS, id: '' } as DPSTargetService,
|
||||
})
|
||||
const [submitting, { setTrue: startSubmit, setFalse: stopSubmit }] =
|
||||
useBoolean(false)
|
||||
|
||||
//#region DROPDOWN ITEMS LOADING STATE
|
||||
const [
|
||||
loadingApps,
|
||||
{ setTrue: startLoadingApps, setFalse: stopLoadingApps },
|
||||
] = useBoolean(false)
|
||||
const [
|
||||
loadingDPSs,
|
||||
{ setTrue: startLoadingDPSs, setFalse: stopLoadingDPSs },
|
||||
] = useBoolean(false)
|
||||
const [
|
||||
loadingGroups,
|
||||
{ setTrue: startLoadingGroups, setFalse: stopLoadingGroups },
|
||||
] = useBoolean(false)
|
||||
const [
|
||||
loadingTemplates,
|
||||
{ setTrue: startLoadingTemplates, setFalse: stopLoadingTemplates },
|
||||
] = useBoolean(false)
|
||||
|
||||
const [
|
||||
loadingHubs,
|
||||
{ setTrue: startLoadingHubs, setFalse: stopLoadingHubs },
|
||||
] = useBoolean(false)
|
||||
//#endregion
|
||||
|
||||
//#region DROPDOWN ITEMS STATE
|
||||
const [centralApps, setCentralApps] = React.useState<IDropdownOption[]>(
|
||||
[]
|
||||
)
|
||||
const [DPSs, setDPSs] = React.useState<IDropdownOption[]>([])
|
||||
const [deviceGroups, setDeviceGroups] = React.useState<
|
||||
IDropdownOption[]
|
||||
>([])
|
||||
const [deviceTemplates, setDeviceTemplates] = React.useState<
|
||||
IDropdownOption[]
|
||||
>([])
|
||||
//#endregion
|
||||
|
||||
//#region DATA LOADING CALLBACK
|
||||
const onCentralAppLoading = React.useCallback(() => {
|
||||
return _onDataLoading(
|
||||
'Select an IoT Central application...',
|
||||
loadingApps
|
||||
)
|
||||
}, [loadingApps])
|
||||
|
||||
const onDPSLoading = React.useCallback(() => {
|
||||
return _onDataLoading('Select an IoT DPS instance...', loadingDPSs)
|
||||
}, [loadingDPSs])
|
||||
|
||||
const onDeviceGroupLoading = React.useCallback(() => {
|
||||
return _onDataLoading('Select a device group...', loadingGroups)
|
||||
}, [loadingGroups])
|
||||
|
||||
const onTemplatesLoading = React.useCallback(() => {
|
||||
return _onDataLoading(
|
||||
'Select a device template...',
|
||||
loadingTemplates
|
||||
)
|
||||
}, [loadingTemplates])
|
||||
//#endregion
|
||||
|
||||
//#region DROPDOWNS IN OPENING STATE
|
||||
const onCentralDropDownOpen = React.useCallback(async () => {
|
||||
if (centralApps.length === 0) {
|
||||
await _onDropDownOpen(
|
||||
listCentralApps,
|
||||
setCentralApps,
|
||||
startLoadingApps,
|
||||
stopLoadingApps
|
||||
)
|
||||
}
|
||||
}, [centralApps, startLoadingApps, stopLoadingApps])
|
||||
|
||||
const onDpsDropDownOpen = React.useCallback(async () => {
|
||||
if (DPSs.length === 0) {
|
||||
await _onDropDownOpen(
|
||||
listDPSs,
|
||||
setDPSs,
|
||||
startLoadingDPSs,
|
||||
stopLoadingDPSs
|
||||
)
|
||||
}
|
||||
}, [DPSs, startLoadingDPSs, stopLoadingDPSs])
|
||||
|
||||
const onDeviceGroupDropDownOpen = React.useCallback(async () => {
|
||||
await _onDropDownOpen(
|
||||
() => {
|
||||
return listDeviceGroups(values.source.id)
|
||||
},
|
||||
setDeviceGroups,
|
||||
startLoadingGroups,
|
||||
stopLoadingGroups
|
||||
)
|
||||
}, [startLoadingGroups, stopLoadingGroups, values.source])
|
||||
|
||||
const onDeviceTemplateDropDownOpen = React.useCallback(async () => {
|
||||
await _onDropDownOpen(
|
||||
() => {
|
||||
const appId =
|
||||
values.mode === MigrationMode.ToHub
|
||||
? values.source.id
|
||||
: values.target.id
|
||||
return listDeviceTemplates(appId)
|
||||
},
|
||||
setDeviceTemplates,
|
||||
startLoadingTemplates,
|
||||
stopLoadingTemplates
|
||||
)
|
||||
}, [startLoadingTemplates, stopLoadingTemplates, values])
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region SET VALUES
|
||||
const setCentralSourceService = React.useCallback(
|
||||
(source: RecursivePartial<CentralSourceService>) => {
|
||||
setValues((cur) => ({
|
||||
...cur,
|
||||
source: {
|
||||
...cur.source,
|
||||
...source,
|
||||
type: ServiceType.Central,
|
||||
params: {
|
||||
...cur.source.params,
|
||||
...source.params,
|
||||
},
|
||||
} as CentralSourceService,
|
||||
}))
|
||||
},
|
||||
[setValues]
|
||||
)
|
||||
|
||||
const setCentralTargetService = React.useCallback(
|
||||
(target: RecursivePartial<CentralTargetService>) => {
|
||||
setValues((cur) => ({
|
||||
...cur,
|
||||
target: {
|
||||
...cur.target,
|
||||
...target,
|
||||
type: ServiceType.Central,
|
||||
params: {
|
||||
...cur.target.params,
|
||||
...target.params,
|
||||
},
|
||||
} as CentralTargetService,
|
||||
}))
|
||||
},
|
||||
[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) => ({
|
||||
...cur,
|
||||
source: {
|
||||
...cur.source,
|
||||
...source,
|
||||
type: ServiceType.DPS,
|
||||
params: {
|
||||
...cur.source.params,
|
||||
...source.params,
|
||||
},
|
||||
} as DPSSourceService,
|
||||
}))
|
||||
},
|
||||
[setValues]
|
||||
)
|
||||
|
||||
const selectSourceHub = React.useCallback(
|
||||
(hubIndex: number, selected: boolean) => {
|
||||
setValues((cur) => {
|
||||
const iothubs = (cur.source.params as DPSSourceParams)
|
||||
.iothubs
|
||||
return {
|
||||
...cur,
|
||||
type: ServiceType.Central,
|
||||
source: {
|
||||
...cur.source,
|
||||
params: {
|
||||
...cur.source.params,
|
||||
iothubs: [
|
||||
...iothubs.slice(0, hubIndex),
|
||||
{ ...iothubs[hubIndex], selected },
|
||||
...iothubs.slice(hubIndex + 1),
|
||||
],
|
||||
},
|
||||
} as DPSSourceService,
|
||||
}
|
||||
})
|
||||
},
|
||||
[setValues]
|
||||
)
|
||||
//#endregion
|
||||
//#region GENERAL CALLBACKS
|
||||
const onDeviceTemplateSelected = React.useCallback(
|
||||
async (template) => {
|
||||
const { capabilityModel } = template
|
||||
const migrationComponent = findComponent(
|
||||
capabilityModel,
|
||||
'dtmi:azureiot:DeviceMigration;1'
|
||||
)
|
||||
if (!migrationComponent) {
|
||||
setErrorMessage(
|
||||
new ApiError(
|
||||
'Invalid device template',
|
||||
'The selected device template does not contain the Device Migration component.\nPlease include the component as descrived in the documentation.'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setCentralSourceService({
|
||||
params: {
|
||||
deviceTemplateId: template['@id'],
|
||||
componentName: migrationComponent.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[setErrorMessage, setCentralSourceService]
|
||||
)
|
||||
|
||||
const onSourceDPSSelected = React.useCallback(
|
||||
async (dpsData) => {
|
||||
startLoadingHubs()
|
||||
const availableHubs = await listHubs()
|
||||
const associatedHubNames = dpsData.data.properties.iotHubs.map(
|
||||
(h: any) => h.name
|
||||
)
|
||||
const associatedHubs = filterHubs(
|
||||
availableHubs,
|
||||
associatedHubNames
|
||||
)
|
||||
setDPSSourceService({
|
||||
id: dpsData?.key as string,
|
||||
params: {
|
||||
iothubs: await Promise.all(
|
||||
associatedHubs.map(async (h: any) => {
|
||||
const hubKeys = await getHubKeys(
|
||||
h.id,
|
||||
'registryRead'
|
||||
)
|
||||
const sasToken = generateSaSToken(
|
||||
h.properties.hostName,
|
||||
hubKeys.primaryKey,
|
||||
'registryRead'
|
||||
)
|
||||
return {
|
||||
name: h.name,
|
||||
host: h.properties.hostName,
|
||||
id: h.id,
|
||||
sasToken,
|
||||
selected: false,
|
||||
}
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
stopLoadingHubs()
|
||||
},
|
||||
[startLoadingHubs, stopLoadingHubs, setDPSSourceService]
|
||||
)
|
||||
|
||||
const onSubmit = React.useCallback(async () => {
|
||||
if (
|
||||
values.source.type === ServiceType.Central &&
|
||||
values.target.type === ServiceType.DPS
|
||||
) {
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (err) {
|
||||
setErrorMessage(err as ApiError)
|
||||
}
|
||||
} else if (
|
||||
values.source.type === ServiceType.DPS &&
|
||||
values.target.type === ServiceType.Central
|
||||
) {
|
||||
// list devices in selected hubs
|
||||
const devices = (
|
||||
await Promise.all(
|
||||
values.source.params.iothubs
|
||||
.filter((h) => h.selected)
|
||||
.map(async (h) => {
|
||||
return listDevicesInHub(h.host, h.sasToken)
|
||||
})
|
||||
)
|
||||
).flat()
|
||||
console.log(devices)
|
||||
}
|
||||
stopSubmit()
|
||||
}, [values, setErrorMessage, stopSubmit])
|
||||
|
||||
//#endregion
|
||||
return (
|
||||
<div className='page'>
|
||||
<h2>New Migration</h2>
|
||||
<div className='formHeader'>
|
||||
<span>
|
||||
Move devices from an Azure IoT Central application to
|
||||
another one or to an Azure IoT Hub and back.
|
||||
</span>
|
||||
</div>
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={nameId} required>
|
||||
Name
|
||||
</Label>
|
||||
<TextField
|
||||
id={nameId}
|
||||
name='name'
|
||||
onChange={(_, name) =>
|
||||
setValues((cur) => ({
|
||||
...cur,
|
||||
name: name!,
|
||||
}))
|
||||
}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={modeId}>Migration mode</Label>
|
||||
<ChoiceGroup
|
||||
id={modeId}
|
||||
options={modeOptions}
|
||||
selectedKey={values.mode}
|
||||
onChange={(_, mode) =>
|
||||
setValues((cur) => ({
|
||||
...cur,
|
||||
mode: MigrationMode[
|
||||
mode?.key as keyof typeof MigrationMode
|
||||
],
|
||||
}))
|
||||
}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className='center-vertical'>
|
||||
<div className='spaced-right flex horizontal'>
|
||||
{/** ----------------- SOURCE -------------------- */}
|
||||
<div id='source' className='section'>
|
||||
<Label htmlFor='source'>Source</Label>
|
||||
{values.mode === MigrationMode.FromHub ? (
|
||||
<>
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={dpsId} required>
|
||||
IoT DPS instance
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={dpsId}
|
||||
options={DPSs}
|
||||
onRenderPlaceholder={onDPSLoading}
|
||||
disabled={submitting}
|
||||
onClick={onDpsDropDownOpen}
|
||||
onChange={async (_, val) => {
|
||||
await onSourceDPSSelected(val)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{values.source.id &&
|
||||
(values.source as DPSSourceService)
|
||||
.params.iothubs && (
|
||||
<Stack className='formInput'>
|
||||
<Label>Associated Hubs</Label>
|
||||
{(
|
||||
values.source as DPSSourceService
|
||||
).params.iothubs.map(
|
||||
(iothub, idx) => (
|
||||
<Checkbox
|
||||
key={`hub-${idx}`}
|
||||
label={iothub.name}
|
||||
checked={
|
||||
iothub.selected
|
||||
}
|
||||
onChange={(
|
||||
_,
|
||||
val
|
||||
) =>
|
||||
selectSourceHub(
|
||||
idx,
|
||||
val!
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{loadingHubs && (
|
||||
<Spinner
|
||||
size={SpinnerSize.medium}
|
||||
className='spaced-left'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={centralAppsId} required>
|
||||
IoT Central Application
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={centralAppsId}
|
||||
options={centralApps}
|
||||
disabled={submitting}
|
||||
onRenderPlaceholder={
|
||||
onCentralAppLoading
|
||||
}
|
||||
onClick={onCentralDropDownOpen}
|
||||
onChange={(_, val) => {
|
||||
setCentralTargetService({
|
||||
id: val?.data.properties
|
||||
.subdomain,
|
||||
})
|
||||
setDeviceGroups([])
|
||||
setDeviceTemplates([])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='formInput'>
|
||||
<Label
|
||||
htmlFor={deviceGroupsId}
|
||||
required
|
||||
>
|
||||
Device group
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={deviceGroupsId}
|
||||
options={deviceGroups}
|
||||
onRenderPlaceholder={
|
||||
onDeviceGroupLoading
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
values.source.id &&
|
||||
!submitting
|
||||
) {
|
||||
await onDeviceGroupDropDownOpen()
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!values.source.id || submitting
|
||||
}
|
||||
onChange={(_, val) => {
|
||||
setCentralSourceService({
|
||||
params: {
|
||||
groupId: val?.data.id,
|
||||
},
|
||||
})
|
||||
setDeviceTemplates([])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='formInput'>
|
||||
<Label
|
||||
htmlFor={deviceTemplateId}
|
||||
required
|
||||
>
|
||||
Filter by template
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={deviceTemplateId}
|
||||
options={deviceTemplates}
|
||||
onRenderPlaceholder={
|
||||
onTemplatesLoading
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
values.source.id &&
|
||||
!submitting
|
||||
) {
|
||||
await onDeviceTemplateDropDownOpen()
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!values.source.id || submitting
|
||||
}
|
||||
onChange={(_, val) =>
|
||||
onDeviceTemplateSelected(
|
||||
val?.data
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='center-vertical spaced-right spaced-left'>
|
||||
<Icon
|
||||
iconName='DoubleChevronRight'
|
||||
className={`migration-arrow${
|
||||
submitting ? '-disabled' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{/** ----------------- TARGET -------------------- */}
|
||||
<div id='target' className='section'>
|
||||
<Label htmlFor='target'>Target</Label>
|
||||
{values.mode === MigrationMode.ToHub ? (
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={dpsId} required>
|
||||
IoT DPS instance
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={dpsId}
|
||||
options={DPSs}
|
||||
onRenderPlaceholder={onDPSLoading}
|
||||
onClick={onDpsDropDownOpen}
|
||||
onChange={(_, val) => {
|
||||
setValues((cur) => ({
|
||||
...cur,
|
||||
target: {
|
||||
type: ServiceType.DPS,
|
||||
id: val?.key as string,
|
||||
params: {
|
||||
...cur.target.params,
|
||||
idScope:
|
||||
val?.data.properties
|
||||
.idScope,
|
||||
dpsName: val?.data.name,
|
||||
dpsId: val?.data
|
||||
.dpsLink,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='formInput'>
|
||||
<Label
|
||||
htmlFor={centralAppsId2}
|
||||
required
|
||||
>
|
||||
IoT Central Application
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={centralAppsId2}
|
||||
options={centralApps}
|
||||
onRenderPlaceholder={
|
||||
onCentralAppLoading
|
||||
}
|
||||
onClick={onCentralDropDownOpen}
|
||||
onChange={(_, val) => {
|
||||
setCentralTargetService({
|
||||
id: val?.data.properties
|
||||
.subdomain,
|
||||
})
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className='formInput'>
|
||||
<Label htmlFor={deviceTemplateId}>
|
||||
Assign to template (optional)
|
||||
</Label>
|
||||
<Dropdown
|
||||
id={deviceTemplateId}
|
||||
options={deviceTemplates}
|
||||
onRenderPlaceholder={
|
||||
onTemplatesLoading
|
||||
}
|
||||
onClick={async () => {
|
||||
if (
|
||||
values.target.id &&
|
||||
!submitting
|
||||
) {
|
||||
await onDeviceTemplateDropDownOpen()
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!values.target.id || submitting
|
||||
}
|
||||
onChange={(_, val) => {
|
||||
setCentralTargetService({
|
||||
params: {
|
||||
deviceTemplateId:
|
||||
val?.data['@id'],
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='center-vertical bottom-margin'>
|
||||
<PrimaryButton
|
||||
text='Migrate'
|
||||
disabled={
|
||||
submitting ||
|
||||
!values.name ||
|
||||
!values.source.id ||
|
||||
!values.target.id
|
||||
}
|
||||
onClick={() => {
|
||||
startSubmit()
|
||||
onSubmit()
|
||||
}}
|
||||
className='spaced-right'
|
||||
/>
|
||||
<PrimaryButton
|
||||
text='Test'
|
||||
onClick={async () => {
|
||||
const policy = 'registryRead'
|
||||
const keys = await getHubKeys(
|
||||
'/subscriptions/2efa8bb6-25bf-4895-ba64-33806dd00780/resourceGroups/paas/providers/Microsoft.Devices/IotHubs/migratorhub',
|
||||
policy
|
||||
)
|
||||
console.log(
|
||||
generateSaSToken(
|
||||
'migratorhub.devices.net',
|
||||
keys.primaryKey,
|
||||
policy
|
||||
)
|
||||
)
|
||||
}}
|
||||
className='spaced-right'
|
||||
/>
|
||||
{submitting && (
|
||||
<Spinner
|
||||
size={SpinnerSize.medium}
|
||||
className='spaced-left'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
|
@ -1,56 +0,0 @@
|
|||
.workspace {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-container {
|
||||
padding: 0 32px;
|
||||
background-color: var(--color-content-background-tertiary);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.device-count {
|
||||
> span:nth-child(1) {
|
||||
font-weight: 1000;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-group:not(:first-child) {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.no-margin-top {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
height: 16px;
|
||||
background-color: var(--color-content-background-tertiary);
|
||||
}
|
||||
|
||||
.message-bar {
|
||||
width: 800px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -1,260 +0,0 @@
|
|||
import classnames from 'classnames/bind';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { TextField } from '@fluentui/react/lib/TextField';
|
||||
import { ChoiceGroup, IChoiceGroupOption } from '@fluentui/react/lib/ChoiceGroup';
|
||||
import { Dropdown } from '@fluentui/react/lib/Dropdown';
|
||||
import { Checkbox } from '@fluentui/react/lib/Checkbox';
|
||||
import { PrimaryButton } from '@fluentui/react';
|
||||
import { CommandBar, ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
|
||||
import { MessageBar, MessageBarType } from '@fluentui/react/lib/MessageBar';
|
||||
import { ProgressIndicator } from '@fluentui/react/lib/ProgressIndicator';
|
||||
import { getTextFieldStyles, dropdownStyles } from '../../styles/fluentStyles';
|
||||
import usePromise from '../../hooks/usePromise';
|
||||
import { AuthContext } from '../../context/authContext';
|
||||
import { AppDataContext } from '../../context/appDataContext';
|
||||
import { getDPS, getSubscriptionsApps, JobPayload, postJob } from '../../iotcAPI';
|
||||
import { Config } from '../../config';
|
||||
|
||||
const cx = classnames.bind(require('./newMigration.scss'));
|
||||
|
||||
const enum MigrationOptions {
|
||||
App = 'App',
|
||||
Hub = 'Hub'
|
||||
}
|
||||
|
||||
const options: IChoiceGroupOption[] = [
|
||||
{ key: MigrationOptions.Hub, text: 'Move to your own Azure IoT Hub' },
|
||||
{ key: MigrationOptions.App, text: 'Move to another Azure IoT Central application', disabled: true }
|
||||
];
|
||||
|
||||
export default React.memo(function NewMigration() {
|
||||
|
||||
const formRef = React.useRef<any>(null);
|
||||
const authContext = React.useContext(AuthContext);
|
||||
const appDataContext = React.useContext(AppDataContext);
|
||||
|
||||
const { control, register, handleSubmit, watch } = useForm<JobPayload>();
|
||||
const optionWatch = watch('migrationOption', undefined);
|
||||
|
||||
const [loadingTargets, targetsList, fetchTargetsError, fetchTargets] = usePromise();
|
||||
const [submittingJob, jobResult, submitError, createJob] = usePromise();
|
||||
|
||||
const pageDisabled = !submittingJob && !!jobResult;
|
||||
|
||||
const cmdSubmit = () => { formRef.current.click(); }
|
||||
|
||||
const onSubmit = (data) => {
|
||||
createJob({ promiseFn: () => postJob(authContext, data) });
|
||||
}
|
||||
|
||||
const [error, setError] = React.useState<boolean>(false);
|
||||
|
||||
|
||||
const cmdBar: ICommandBarItemProps[] = React.useMemo(() => [{
|
||||
key: '1',
|
||||
text: 'Migrate',
|
||||
iconProps: {
|
||||
iconName: 'TurnRight'
|
||||
},
|
||||
onClick: cmdSubmit,
|
||||
disabled: pageDisabled
|
||||
}], [pageDisabled]);
|
||||
|
||||
const cmdBarNew: ICommandBarItemProps[] = React.useMemo(() => [{
|
||||
key: '2',
|
||||
text: 'New migration',
|
||||
iconProps: {
|
||||
iconName: 'Add'
|
||||
},
|
||||
onClick: cmdSubmit,
|
||||
disabled: submittingJob
|
||||
}], [submittingJob]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (optionWatch === MigrationOptions.App) {
|
||||
fetchTargets({ promiseFn: () => getSubscriptionsApps(authContext) });
|
||||
}
|
||||
if (optionWatch === MigrationOptions.Hub) {
|
||||
fetchTargets({ promiseFn: () => getDPS(authContext) });
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [authContext, optionWatch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (submitError || fetchTargetsError) {
|
||||
setError(true);
|
||||
}
|
||||
else {
|
||||
setError(false);
|
||||
}
|
||||
}, [fetchTargetsError, submitError]);
|
||||
|
||||
const errorData = submitError || fetchTargetsError;
|
||||
|
||||
return (
|
||||
<div className={cx('workspace')}>
|
||||
<CommandBar className={cx('action-bar')} items={!!pageDisabled ? cmdBarNew : cmdBar} />
|
||||
{error && <MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
onDismiss={() => setError(false)}>
|
||||
<p>{JSON.stringify(errorData?.response?.data?.error?.message || errorData.message || 'Something went wrong. Please try again')}</p>
|
||||
</MessageBar>}
|
||||
{pageDisabled && <MessageBar
|
||||
messageBarType={MessageBarType.success}
|
||||
isMultiline={false}>
|
||||
{`Migration job submitted with status ${jobResult.status}`}
|
||||
<Link to="/status">Migration status</Link>
|
||||
{' page to see progress of migration'}
|
||||
</MessageBar>}
|
||||
<div className={cx('workspace-container')}>
|
||||
<div className={cx('workspace-title')}>
|
||||
<h1 className={cx('title')}>New migration</h1>
|
||||
<p>Move devices from <span className={cx('app-name')}>{Config.applicationHost}</span> to another Azure IoT Central application or to your own Azure IoT Hub.</p>
|
||||
</div>
|
||||
<div className={cx('workspace-content')}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={cx('workspace-narrow')}>
|
||||
|
||||
<div className={cx('field-group')}>
|
||||
<div className={cx('text-field')}>
|
||||
<TextField
|
||||
autoFocus
|
||||
required={true}
|
||||
disabled={pageDisabled}
|
||||
autoComplete='off'
|
||||
styles={getTextFieldStyles}
|
||||
label='Name'
|
||||
{...register('migrationName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx('field-group')}>
|
||||
<h3>Target devices</h3>
|
||||
<p>Choose the device group containing the devices to migrate.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name='group'
|
||||
render={({ field }) =>
|
||||
<Dropdown
|
||||
{...field}
|
||||
disabled={pageDisabled}
|
||||
onChange={(_e, v: any) => { field.onChange(v.key) }}
|
||||
required={true}
|
||||
placeholder='Select a device group'
|
||||
label='Device group'
|
||||
options={appDataContext.groups || []}
|
||||
styles={dropdownStyles}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<p>Choose the device template that contains the device move Direct Method convention.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name='template'
|
||||
render={({ field }) =>
|
||||
<Dropdown
|
||||
{...field}
|
||||
disabled={pageDisabled}
|
||||
onChange={(_e, v: any) => { field.onChange(v.key) }}
|
||||
required={true}
|
||||
placeholder='Select a device template'
|
||||
label='Filter group by device template'
|
||||
options={appDataContext.templates || []}
|
||||
styles={dropdownStyles}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx('field-group')}>
|
||||
<h3>Migration options</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name='migrationOption'
|
||||
render={({ field }) =>
|
||||
<ChoiceGroup
|
||||
{...field}
|
||||
disabled={pageDisabled}
|
||||
onChange={(_e, v: any) => { field.onChange(v.key) }}
|
||||
options={options}
|
||||
label='Select a migration option'
|
||||
required={true}
|
||||
/>}
|
||||
/>
|
||||
<div className={cx('progress-indicator')}>{optionWatch && loadingTargets && <ProgressIndicator />}</div>
|
||||
</div>
|
||||
|
||||
{!!optionWatch && <>
|
||||
<div className={cx('field-group', { 'loading': optionWatch && loadingTargets })}>
|
||||
<h3>Migration target</h3>
|
||||
{optionWatch === MigrationOptions.Hub && <>
|
||||
<p className={cx("no-margin-bottom")}>Choose the Device Provisioning Service (DPS) linked to an Azure IoT Hub where the devices will be moved.</p>
|
||||
<p>You can set the same group SAS tokens in both IoT Hub and IoT Central so that your devices can connect to either solutions.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name='target'
|
||||
render={({ field }) =>
|
||||
<Dropdown
|
||||
{...field}
|
||||
disabled={pageDisabled}
|
||||
onChange={(_e, v: any) => { field.onChange(v.key) }}
|
||||
required={true}
|
||||
placeholder='Select a DPS'
|
||||
label='Target DPS'
|
||||
options={targetsList || []}
|
||||
styles={dropdownStyles} />
|
||||
}
|
||||
/>
|
||||
<MessageBar className={cx('message-bar')} messageBarType={MessageBarType.warning}>
|
||||
Please ensure that the device group enrollment or X.509 authentication details are copied from the Central application to this DPS instance
|
||||
</MessageBar>
|
||||
</>}
|
||||
</div>
|
||||
{optionWatch === MigrationOptions.App &&
|
||||
<div className={cx('field-group', 'no-margin-top', { 'loading': optionWatch && loadingTargets })}>
|
||||
<p className={cx("no-margin-bottom")}>Choose the target application where the devices will be moved.</p>
|
||||
<p>You can set the same group SAS tokens in both IoT Hub and IoT Central so that your devices can connect to either solutions.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name='target'
|
||||
render={({ field }) =>
|
||||
<Dropdown
|
||||
{...field}
|
||||
disabled={pageDisabled}
|
||||
onChange={(_e, v: any) => { field.onChange(v.key) }}
|
||||
required={true}
|
||||
placeholder='Select an application'
|
||||
label='Target application'
|
||||
options={targetsList || []}
|
||||
styles={dropdownStyles} />
|
||||
}
|
||||
/>
|
||||
<h3>Device template</h3>
|
||||
<Controller
|
||||
control={control}
|
||||
name='template'
|
||||
defaultValue={false}
|
||||
render={({ field }) =>
|
||||
<Checkbox disabled={pageDisabled} label='Copy the associated device template' {...field} />
|
||||
}
|
||||
/>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
<div className={cx('field-group')}>
|
||||
<PrimaryButton disabled={pageDisabled} type='submit' text='Migrate' />
|
||||
{/* this is hidden to allow the cmd bar to submit too */}
|
||||
<button hidden={true} ref={formRef} type='submit' />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
});
|
|
@ -1,55 +0,0 @@
|
|||
.workspace {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-container {
|
||||
padding: 0 32px;
|
||||
background-color: var(--color-content-background-tertiary);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding-left: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding-top: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
height: 2.6rem;
|
||||
border-bottom: 1px solid #f4f3f2;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
padding: 0 2rem;
|
||||
height: 18px;
|
||||
background-color: var(--color-content-background-tertiary);
|
||||
}
|
||||
|
||||
.status-padding {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.ms-DetailsHeader {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 1.5rem;
|
||||
background-color: white;
|
||||
border: 1px solid #f4f3f2;
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
import classnames from 'classnames/bind';
|
||||
|
||||
import React from 'react';
|
||||
import { DetailsList, DetailsRow, ConstrainMode, SelectionMode, MessageBar, MessageBarType } from '@fluentui/react'
|
||||
import { CommandBar, ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
|
||||
import usePromise from '../../hooks/usePromise';
|
||||
import { AuthContext } from '../../context/authContext';
|
||||
import { AppDataContext } from '../../context/appDataContext';
|
||||
import { ProgressIndicator } from '@fluentui/react/lib/ProgressIndicator';
|
||||
import { getJobs, Row } from '../../iotcAPI';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const cx = classnames.bind(require('./status.scss'));
|
||||
|
||||
function Status() {
|
||||
const authContext = React.useContext(AuthContext);
|
||||
const appDataContext = React.useContext(AppDataContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tableData, setTableData] = React.useState<Row[]>([]);
|
||||
const [loading, data, error, fetch] = usePromise();
|
||||
|
||||
const newMigration = React.useCallback(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
|
||||
const cmdBar: ICommandBarItemProps[] = React.useMemo(() => [{
|
||||
key: '1',
|
||||
text: 'Refresh',
|
||||
iconProps: {
|
||||
iconName: 'Refresh'
|
||||
},
|
||||
onClick: () => { fetch({ promiseFn: () => getJobs(authContext) }); },
|
||||
disabled: false
|
||||
}, {
|
||||
key: '2',
|
||||
text: 'New migration',
|
||||
iconProps: {
|
||||
iconName: 'Add'
|
||||
},
|
||||
onClick: newMigration,
|
||||
}], [authContext, fetch, newMigration]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalId = setInterval(async () => {
|
||||
const response = await fetch({ promiseFn: () => getJobs(authContext) });
|
||||
if (!!response) {
|
||||
setTableData(response);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [setTableData, fetch, authContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!data) { return; }
|
||||
setTableData(data);
|
||||
}, [data])
|
||||
|
||||
const _onRenderRow = (props) => {
|
||||
if (!props) {
|
||||
return null;
|
||||
}
|
||||
return <DetailsRow {...props} className={cx('row')} />;
|
||||
};
|
||||
|
||||
const cols = React.useMemo(() => {
|
||||
return [
|
||||
{ key: '1', name: 'Migration job name', fieldName: 'id', isResizable: true, minWidth: 150, maxWidth: 350 },
|
||||
{ key: '2', name: 'Device group', fieldName: 'dgroup', isResizable: true, minWidth: 150, maxWidth: 350 },
|
||||
{ key: '3', name: 'Status', fieldName: 'status', isResizable: true, minWidth: 150, maxWidth: 350 }
|
||||
];
|
||||
}, []);
|
||||
|
||||
const _onRenderItemColumn = (item, _index, column) => {
|
||||
switch (column.fieldName) {
|
||||
case 'dgroup':
|
||||
const groupId = item.dgroup;
|
||||
const group = appDataContext.groups.find(g => g.key === groupId);
|
||||
|
||||
return <a target='_blank' rel='noreferrer' href={`https://${authContext.applicationHost}/device-groups/${group?.key}`}>
|
||||
{group?.text}
|
||||
</a>;
|
||||
case 'id':
|
||||
return <a target='_blank' rel='noreferrer' href={`https://${authContext.applicationHost}/jobs/instances/${item[column.fieldName]}`}>
|
||||
{item.name}
|
||||
</a>;
|
||||
default:
|
||||
return <span>{item[column.fieldName]}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('workspace')}>
|
||||
<CommandBar className={cx('action-bar')} items={cmdBar} />
|
||||
{error && <MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}>
|
||||
<p>{JSON.stringify(error?.response?.data?.error?.message || error.message || 'Something went wrong. Please try again')}</p>
|
||||
</MessageBar>}
|
||||
<div className={cx('status-progress')}>{!!loading && <ProgressIndicator />}</div>
|
||||
<div className={cx('workspace-container')}>
|
||||
<div className={cx('workspace-title')}>
|
||||
<h1 className={cx('title')}>Migration status</h1>
|
||||
<p>Watch all the migration process for <span className={cx('app-name')}>{authContext.applicationHost}</span>.</p>
|
||||
</div>
|
||||
<div className={cx('workspace-content')}>
|
||||
<div className={cx('workspace-table', { 'status-padding': loading })}>
|
||||
<DetailsList
|
||||
compact={true}
|
||||
items={tableData}
|
||||
columns={cols}
|
||||
selectionMode={SelectionMode.none}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
onRenderRow={_onRenderRow}
|
||||
onRenderItemColumn={_onRenderItemColumn}
|
||||
setKey='set'
|
||||
className={cx('table')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Status;
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -0,0 +1,13 @@
|
|||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
|
@ -1,30 +0,0 @@
|
|||
import classnames from 'classnames/bind';
|
||||
|
||||
import { Paths } from './routes';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { FontIcon } from '@fluentui/react/lib/Icon';
|
||||
|
||||
const cx = classnames.bind(require('./shell.scss'));
|
||||
|
||||
export function Navigation() {
|
||||
return (
|
||||
<>
|
||||
<NavItem to={Paths.home} title='Define a new migration' icon='TurnRight' text='New migration' />
|
||||
<NavItem to={Paths.status} title='Check the status of your migration' icon='Sync' text='Migration status' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, title, icon, text }: {
|
||||
to: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<NavLink to={to} title={title} className={(navData) => cx('global-nav-item', { 'global-nav-item-active': navData.isActive })} >
|
||||
<FontIcon iconName={icon} className={cx('global-nav-item-icon')} />
|
||||
<span className={cx('inline-text-overflow', 'global-nav-item-text')}>{text}</span>
|
||||
</ NavLink >
|
||||
);
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import { Routes as ReactRoutes, Route, } from 'react-router-dom';
|
||||
import NewMigration from '../pages/newMigration/newMigration';
|
||||
import Status from '../pages/status/status';
|
||||
import Shell from './shell';
|
||||
|
||||
export const Paths = {
|
||||
home: '/',
|
||||
status: '/status',
|
||||
};
|
||||
|
||||
export function Routes({ appReady }: { appReady: boolean }) {
|
||||
|
||||
if (!appReady) {
|
||||
return <Shell />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactRoutes>
|
||||
<Route path={Paths.home} element={<NewMigration />} />
|
||||
<Route path={Paths.status} element={<Status />} />
|
||||
</ReactRoutes>
|
||||
);
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
@import "../styles/_constants";
|
||||
@import "../styles/_colors";
|
||||
@import "../styles/_fonts";
|
||||
@import "../styles/_icons";
|
||||
@import "../styles/_mixins";
|
||||
@import "../styles/_typography";
|
||||
@import "~@microsoft/azure-iot-ux-fluent-controls/lib/components/Masthead/Masthead.module.scss";
|
||||
@import "~@microsoft/azure-iot-ux-fluent-controls/lib/components/Shell/shell.module.scss";
|
||||
@import "~@microsoft/azure-iot-ux-fluent-controls/lib/components/Navigation/Navigation.module.scss";
|
||||
|
||||
.shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-initial {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.sign-out {
|
||||
margin-right: 0.5rem;
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: lightgray;
|
||||
}
|
||||
}
|
||||
|
||||
.no-auth {
|
||||
text-align: right;
|
||||
padding: 0 1rem 1rem 0;
|
||||
font-weight: 1000;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 0.6rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.ms-Panel-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { AuthContext } from '../context/authContext';
|
||||
import { AppDataContext } from '../context/appDataContext';
|
||||
import { Shell as FluentShell, NavigationProperties } from '@microsoft/azure-iot-ux-fluent-controls';
|
||||
import { Navigation } from './navigation';
|
||||
import { Routes } from './routes';
|
||||
import { FontIcon } from '@fluentui/react/lib/Icon';
|
||||
import { ProgressIndicator } from '@fluentui/react/lib/ProgressIndicator';
|
||||
import classnames from 'classnames/bind';
|
||||
import { Config } from '../config';
|
||||
import Modal from '../controls/modal';
|
||||
const cx = classnames.bind(require('./shell.scss'));
|
||||
|
||||
function signOut(signOutHandler) {
|
||||
return <div className='sign-out'>
|
||||
<button title='Sign Out' onClick={signOutHandler}><FontIcon iconName='SignOut' className='global-nav-item-icon' /></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default React.memo(function Shell() {
|
||||
const authContext = React.useContext(AuthContext);
|
||||
const appDataContext = React.useContext(AppDataContext);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const [error, setError] = React.useState<{ title: string | undefined, message: string | undefined; }>({
|
||||
title: undefined,
|
||||
message: undefined
|
||||
});
|
||||
const nav: NavigationProperties = {
|
||||
isExpanded: expanded,
|
||||
onClick: (() => setExpanded(!expanded)),
|
||||
children: <Navigation />
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authContext.authenticated && authContext?.error?.name !== 'BrowserAuthError') {
|
||||
authContext.signIn(false);
|
||||
} else if (!appDataContext.appDataReady && !appDataContext.error) {
|
||||
appDataContext.fetchAppData(authContext);
|
||||
}
|
||||
}, [authContext, appDataContext.fetchAppData, authContext.authenticated, appDataContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!authContext.error) {
|
||||
setError({
|
||||
title: 'Error during the authentication',
|
||||
message: authContext.error.message
|
||||
});
|
||||
} else if (!!appDataContext.error) {
|
||||
setError({
|
||||
title: 'Error fetching data from IoT Central',
|
||||
message: appDataContext.error.message
|
||||
});
|
||||
}
|
||||
}, [authContext.error, appDataContext.error]);
|
||||
|
||||
const closeErrorModal = React.useCallback(() => setError({ title: undefined, message: undefined }), []);
|
||||
|
||||
const loadingText = React.useMemo(() => {
|
||||
if (!authContext.authenticated) {
|
||||
return 'Waiting for authentication...';
|
||||
}
|
||||
return `Loading application data from ${Config.applicationHost}...`;
|
||||
|
||||
}, [authContext]);
|
||||
|
||||
|
||||
let content: JSX.Element =
|
||||
<div className={cx("shell-initial")}>
|
||||
<h2>Please wait</h2>
|
||||
<ProgressIndicator label={loadingText} />
|
||||
</div>;
|
||||
|
||||
if (!!error.title) {
|
||||
content = <Modal closeModal={closeErrorModal} error={error} />;
|
||||
}
|
||||
|
||||
if (authContext.authenticated && appDataContext.appDataReady) {
|
||||
content = <Routes appReady={true} />;
|
||||
}
|
||||
|
||||
|
||||
return <div className={cx("shell")}>
|
||||
<FluentShell
|
||||
masthead={{
|
||||
branding: `IoTC Migrator ${!!authContext.applicationHost && `- ${authContext.applicationHost}`}`,
|
||||
user: signOut(authContext.signOut),
|
||||
}}
|
||||
navigation={nav}>
|
||||
{content}
|
||||
</FluentShell>
|
||||
</div >
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import "~@microsoft/azure-iot-ux-fluent-css/src/colors";
|
|
@ -1 +0,0 @@
|
|||
@import "~@microsoft/azure-iot-ux-fluent-css/src/constants";
|
|
@ -1,9 +0,0 @@
|
|||
@import "./constants";
|
||||
|
||||
// specify the segoe icons font src:
|
||||
@font-face {
|
||||
font-family: "icons";
|
||||
font-display: "block"; // don't fallback to other fonts while downloading
|
||||
src: url("./segmdl2.1.61.woff") format("woff"),
|
||||
url("./segmdl2.1.61.ttf") format("truetype");
|
||||
}
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -1 +0,0 @@
|
|||
@import "~@microsoft/azure-iot-ux-fluent-css/src/mixins";
|
|
@ -1 +0,0 @@
|
|||
@import "~@microsoft/azure-iot-ux-fluent-css/src/typography";
|
|
@ -1,23 +0,0 @@
|
|||
import { ITextFieldStyleProps, ITextFieldStyles } from '@fluentui/react/lib/TextField';
|
||||
import { IDropdownStyles } from '@fluentui/react/lib/Dropdown';
|
||||
|
||||
// this is required to style Office Fabric
|
||||
export function getTextFieldStyles(props: ITextFieldStyleProps): Partial<ITextFieldStyles> {
|
||||
return {
|
||||
fieldGroup: [{
|
||||
border: (props.focused ? '1px solid #136bfb' : '1px solid #cdcdcd'),
|
||||
selectors: {
|
||||
":hover": {
|
||||
border: '1px solid #136bfb',
|
||||
}
|
||||
},
|
||||
width: 400,
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export const dropdownStyles: Partial<IDropdownStyles> = {
|
||||
dropdown: {
|
||||
width: 400,
|
||||
},
|
||||
};
|
Двоичные данные
src/styles/segmdl2.1.61.ttf
Двоичные данные
src/styles/segmdl2.1.61.ttf
Двоичный файл не отображается.
Двоичные данные
src/styles/segmdl2.1.61.woff
Двоичные данные
src/styles/segmdl2.1.61.woff
Двоичный файл не отображается.
|
@ -0,0 +1,135 @@
|
|||
export const JOB_DESCRIPTION = 'AUTOMATED-DEVICE-MOVE'
|
||||
|
||||
export enum MigrationMode {
|
||||
Central = 'Central',
|
||||
FromHub = 'FromHub',
|
||||
ToHub = 'ToHub',
|
||||
}
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
name: string
|
||||
mode: MigrationMode
|
||||
source: SourceService
|
||||
target: TargetService
|
||||
}
|
||||
|
||||
export enum ServiceType {
|
||||
Central = 'Central',
|
||||
DPS = 'Dps',
|
||||
DeviceGroup = 'DeviceGroup',
|
||||
DeviceTemplate = 'DeviceTemplate',
|
||||
}
|
||||
|
||||
export type CentralSourceParams = {
|
||||
groupId: string
|
||||
deviceTemplateId: string
|
||||
componentName: string
|
||||
}
|
||||
|
||||
export type CentralTargetParams = {
|
||||
deviceTemplateId: string
|
||||
}
|
||||
|
||||
export type DPSSourceParams = {
|
||||
iothubs: {
|
||||
name: string
|
||||
host: string
|
||||
id: string
|
||||
sasToken: string
|
||||
selected: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export type DPSTargetParams = {
|
||||
idScope: string
|
||||
dpsName: string
|
||||
dpsId: string
|
||||
}
|
||||
|
||||
export interface ServiceBase {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface CentralSourceService extends ServiceBase {
|
||||
type: ServiceType.Central
|
||||
params: CentralSourceParams
|
||||
}
|
||||
|
||||
export interface DPSSourceService extends ServiceBase {
|
||||
type: ServiceType.DPS
|
||||
params: DPSSourceParams
|
||||
}
|
||||
|
||||
export interface CentralTargetService extends ServiceBase {
|
||||
type: ServiceType.Central
|
||||
params: CentralTargetParams
|
||||
}
|
||||
|
||||
export interface DPSTargetService extends ServiceBase {
|
||||
type: ServiceType.DPS
|
||||
params: DPSTargetParams
|
||||
}
|
||||
|
||||
export type SourceService = CentralSourceService | DPSSourceService
|
||||
export type TargetService = CentralTargetService | DPSTargetService
|
||||
|
||||
export type CommandPayload = {
|
||||
idScope: string
|
||||
dpsName: string
|
||||
dpsId: string
|
||||
}
|
||||
|
||||
export type JobPayload = {
|
||||
displayName: string
|
||||
group: string
|
||||
description: string
|
||||
data: [
|
||||
{
|
||||
type: string
|
||||
target: string
|
||||
path: string
|
||||
value: CommandPayload
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export type JobResult = {
|
||||
id: string
|
||||
displayName: string
|
||||
group: string
|
||||
description?: string
|
||||
data: {
|
||||
type: string
|
||||
target: string
|
||||
path: string
|
||||
value: CommandPayload
|
||||
}[]
|
||||
status: string
|
||||
appName?: string
|
||||
jobLink?: string
|
||||
}
|
||||
|
||||
export const TOKEN_AUDIENCES = {
|
||||
Arm: 'https://management.azure.com/user_impersonation',
|
||||
Central: 'https://apps.azureiotcentral.com/user_impersonation',
|
||||
IoTHub: 'https://iothubs.azure.net/.default',
|
||||
}
|
||||
|
||||
export const API_VERSIONS = {
|
||||
Central: '2022-07-31',
|
||||
Central_Preview: '1.2-preview',
|
||||
ResourceManager: '2021-04-01',
|
||||
DPS: '2022-02-05',
|
||||
IoTHubArm: '2018-04-01',
|
||||
IoTHubData: '2020-05-31-preview',
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public title: string, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import hmacSha256 from 'crypto-js/hmac-sha256'
|
||||
import base64 from 'crypto-js/enc-base64'
|
||||
|
||||
type DTDLCapability = {
|
||||
'@id'?: string
|
||||
'@type': string
|
||||
name: string
|
||||
schema?: any
|
||||
contents?: DTDLCapability[]
|
||||
}
|
||||
export function findComponent(
|
||||
dtdlInterface: DTDLCapability,
|
||||
componentId: string
|
||||
): DTDLCapability | null {
|
||||
let res = null
|
||||
if (
|
||||
dtdlInterface['@type'] === 'Component' &&
|
||||
(dtdlInterface['@id'] === componentId ||
|
||||
dtdlInterface.schema['@id'] === componentId)
|
||||
) {
|
||||
res = dtdlInterface
|
||||
}
|
||||
if (dtdlInterface.contents && dtdlInterface.contents.length > 0) {
|
||||
res =
|
||||
dtdlInterface.contents.find(
|
||||
(c) => findComponent(c, componentId) !== null
|
||||
) || null
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function generateSaSToken(
|
||||
hubName: string,
|
||||
signingKey: string,
|
||||
policyName: string
|
||||
) {
|
||||
const resourceUri = encodeURIComponent(hubName)
|
||||
|
||||
// Set expiration in seconds
|
||||
const expires = Math.ceil(Date.now() / 1000 + 3600)
|
||||
const toSign = resourceUri + '\n' + expires
|
||||
|
||||
// Use crypto
|
||||
const hmac = hmacSha256(toSign, base64.parse(signingKey))
|
||||
const base64UriEncoded = encodeURIComponent(base64.stringify(hmac))
|
||||
|
||||
// Construct authorization string
|
||||
return `SharedAccessSignature sr=${resourceUri}&sig=${base64UriEncoded}&se=${expires}${
|
||||
policyName ? `&skn=${policyName}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
export function filterHubs(hubs: any[], hubHosts: string[]) {
|
||||
const hubNames = hubHosts.map((h) => h.split('.azure-devices.net')[0])
|
||||
return hubs.filter((h) => hubNames.includes(h.name))
|
||||
}
|
127
tsconfig.json
127
tsconfig.json
|
@ -1,30 +1,103 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": true,
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
], "exclude": [
|
||||
"node_modules/**/*"
|
||||
]
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
"jsx": "react", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче