From 91fb26fb4a9500a2d708881c6603c45ce7433148 Mon Sep 17 00:00:00 2001 From: Blair Chen Date: Thu, 2 Jun 2022 13:04:55 +0800 Subject: [PATCH] Feathr UI: API spec alignment and ux experience improvement (#303) This PR update ui code to align with latest api spec which abstracts common interfaces among different registration providers. This PR also enhances lineage ux experience and adds RBAC experience for flighting. New Feature registration API integration API spec can be viewed at https://feathr-registry.azurewebsites.net/docs. UI code is now purview free. UX improvements When a feature is clicked, the feature node is highlighted When a feature is clicked, the feature level lineage path is highlighted. Feature level lineage means direct producer and consumer. Nodes in graph are colored grouped by feature type. User can toggle graph to filter by feature type. Flow arrow is enabled to indicate producer and consumer relationship All out of scope resources are removed in graph, for example, Altas Process, Project. Add navigate button in node so user can click to navigate to feature detail page. RBAC in flgihting, accessible via /management path. Linked issue: #272 --- ui/.env | 1 + ui/package-lock.json | 202 +++++++-------------- ui/package.json | 9 +- ui/src/api/api.tsx | 74 ++++++-- ui/src/api/mock/userrole.json | 63 +++++++ ui/src/app.tsx | 59 ++++++ ui/src/components/dataSourceList.tsx | 9 +- ui/src/components/featureForm.tsx | 10 +- ui/src/components/featureList.tsx | 27 ++- ui/src/components/graph/graph.tsx | 167 +++++++++++++++++ ui/src/components/graph/graphNode.tsx | 45 +++++ ui/src/components/graph/utils.ts | 122 +++++++++++++ ui/src/components/lineage/layouting.css | 12 -- ui/src/components/lineage/lineage.tsx | 89 --------- ui/src/components/roleManagementForm.tsx | 88 +++++++++ ui/src/components/sidemenu/siteMenu.tsx | 10 +- ui/src/components/userRoles.tsx | 148 +++++++++++++++ ui/src/index.tsx | 5 +- ui/src/models/model.ts | 61 ++++--- ui/src/pages/feature/featureDetails.tsx | 101 +++++------ ui/src/pages/feature/featureLineage.tsx | 101 ----------- ui/src/pages/feature/features.tsx | 6 +- ui/src/pages/feature/lineageGraph.tsx | 116 ++++++++++++ ui/src/pages/management/management.tsx | 19 ++ ui/src/pages/management/roleManagement.tsx | 17 ++ ui/src/react-app-env.d.ts | 1 + ui/src/router/routes.tsx | 62 ------- ui/src/site.css | 40 ++++ 28 files changed, 1127 insertions(+), 537 deletions(-) create mode 100644 ui/src/api/mock/userrole.json create mode 100644 ui/src/app.tsx create mode 100644 ui/src/components/graph/graph.tsx create mode 100644 ui/src/components/graph/graphNode.tsx create mode 100644 ui/src/components/graph/utils.ts delete mode 100644 ui/src/components/lineage/layouting.css delete mode 100644 ui/src/components/lineage/lineage.tsx create mode 100644 ui/src/components/roleManagementForm.tsx create mode 100644 ui/src/components/userRoles.tsx delete mode 100644 ui/src/pages/feature/featureLineage.tsx create mode 100644 ui/src/pages/feature/lineageGraph.tsx create mode 100644 ui/src/pages/management/management.tsx create mode 100644 ui/src/pages/management/roleManagement.tsx delete mode 100644 ui/src/router/routes.tsx create mode 100644 ui/src/site.css diff --git a/ui/.env b/ui/.env index c22dbf420..0a9fb0b61 100644 --- a/ui/.env +++ b/ui/.env @@ -1,2 +1,3 @@ REACT_APP_AAD_APP_CLIENT_ID=db8dc4b0-202e-450c-b38d-7396ad9631a5 REACT_APP_AAD_APP_AUTHORITY=https://login.microsoftonline.com/common +REACT_APP_API_ENDPOINT=https://feathr-registry.azurewebsites.net diff --git a/ui/package-lock.json b/ui/package-lock.json index 6052c5bf5..0c83d5ba8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2507,11 +2507,14 @@ "@types/node": "*" } }, - "@types/history": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", - "dev": true + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } }, "@types/html-minifier-terser": { "version": "6.1.0", @@ -2601,8 +2604,7 @@ "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "@types/q": { "version": "1.5.5", @@ -2626,7 +2628,6 @@ "version": "17.0.44", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz", "integrity": "sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g==", - "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -2642,34 +2643,15 @@ "@types/react": "^17" } }, - "@types/react-resizable": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-1.7.4.tgz", - "integrity": "sha512-+xsGkd+Gvb9+8mLR1EyhNN8kBRJcsT1uJF4WpkFpFPIoApX2S89BmJA2RVtMdkhwe6YxV4RbHfaJ3bIdcgHc7g==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, - "@types/react-router": { - "version": "5.1.18", - "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.18.tgz", - "integrity": "sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g==", - "dev": true, - "requires": { - "@types/history": "^4.7.11", - "@types/react": "*" - } - }, - "@types/react-router-dom": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", - "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", - "dev": true, + "@types/react-redux": { + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", + "integrity": "sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==", "requires": { - "@types/history": "^4.7.11", + "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", - "@types/react-router": "*" + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" } }, "@types/resolve": { @@ -2690,8 +2672,7 @@ "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@types/serve-index": { "version": "1.9.1", @@ -4596,8 +4577,7 @@ "csstype": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "dev": true + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" }, "d3-color": { "version": "3.1.0", @@ -5834,8 +5814,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.11", @@ -6404,16 +6383,11 @@ "dev": true }, "history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", "requires": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" + "@babel/runtime": "^7.7.6" } }, "hoist-non-react-statics": { @@ -6966,11 +6940,6 @@ "is-docker": "^2.0.0" } }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8427,15 +8396,6 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true }, - "mini-create-react-context": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", - "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", - "requires": { - "@babel/runtime": "^7.12.1", - "tiny-warning": "^1.0.3" - } - }, "mini-css-extract-plugin": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.0.tgz", @@ -8929,14 +8889,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "requires": { - "isarray": "0.0.1" - } - }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10495,16 +10447,18 @@ "dev": true }, "react-flow-renderer": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.2.1.tgz", - "integrity": "sha512-F2JG9Uc1t7DWzc+HK6REGooW/tt6X5/8frbXnq7ug6yObAPEmfWlTQjRP917cKVkIxFzI1pU38RwHdOoPdsfJw==", + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-9.7.4.tgz", + "integrity": "sha512-GxHBXzkn8Y+TEG8pul7h6Fjo4cKrT0kW9UQ34OAGZqAnSBLbBsx9W++TF8GiULBbTn3O8o7HtHxux685Op10mQ==", "requires": { - "@babel/runtime": "^7.17.9", + "@babel/runtime": "^7.16.7", "classcat": "^5.0.3", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", - "react-draggable": "^4.4.5", - "zustand": "^3.7.2" + "fast-deep-equal": "^3.1.3", + "react-draggable": "^4.4.4", + "react-redux": "^7.2.6", + "redux": "^4.1.2" } }, "react-is": { @@ -10522,50 +10476,47 @@ "match-sorter": "^6.0.2" } }, + "react-redux": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.8.tgz", + "integrity": "sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "dev": true }, - "react-resizable": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz", - "integrity": "sha512-StnwmiESiamNzdRHbSSvA65b0ZQJ7eVQpPusrSmcpyGKzC0gojhtO62xxH6YOBmepk9dQTBi9yxidL3W4s3EBA==", - "requires": { - "prop-types": "15.x", - "react-draggable": "^4.0.3" - } - }, "react-router": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.1.tgz", - "integrity": "sha512-v+zwjqb7bakqgF+wMVKlAPTca/cEmPOvQ9zt7gpSNyPXau1+0qvuYZ5BWzzNDP1y6s15zDwgb9rPN63+SIniRQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", "requires": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "mini-create-react-context": "^0.4.0", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "history": "^5.2.0" } }, "react-router-dom": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.1.tgz", - "integrity": "sha512-f0pj/gMAbv9e8gahTmCEY20oFhxhrmHwYeIwH5EO5xu0qme+wXtsdB8YfUOAZzUz4VaXmb58m3ceiLtjMhqYmQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", "requires": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.1", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "history": "^5.2.0", + "react-router": "6.3.0" } }, "react-scripts": { @@ -10674,6 +10625,14 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -10902,11 +10861,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, - "resolve-pathname": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" - }, "resolve-url-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", @@ -11860,16 +11814,6 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, - "tiny-invariant": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", - "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -12172,11 +12116,6 @@ } } }, - "value-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12883,11 +12822,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true - }, - "zustand": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", - "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==" } } } diff --git a/ui/package.json b/ui/package.json index bfd29e6ca..1663868b0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,11 +10,9 @@ "dagre": "^0.8.5", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-flow-renderer": "^10.2.1", + "react-flow-renderer": "^9.7.4", "react-query": "^3.38.0", - "react-resizable": "^3.0.4", - "react-router": "^5.0.1", - "react-router-dom": "^5.1.2" + "react-router-dom": "^6.3.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.3", @@ -25,9 +23,6 @@ "@types/node": "^16.11.26", "@types/react": "^17.0.43", "@types/react-dom": "^17.0.14", - "@types/react-resizable": "^1.7.4", - "@types/react-router": "^5.1.8", - "@types/react-router-dom": "^5.1.4", "react-scripts": "5.0.0", "typescript": "^4.6.3", "web-vitals": "^2.1.4" diff --git a/ui/src/api/api.tsx b/ui/src/api/api.tsx index 017c944c5..653a2677a 100644 --- a/ui/src/api/api.tsx +++ b/ui/src/api/api.tsx @@ -1,13 +1,13 @@ import Axios from "axios"; -import { Features, IDataSource, IFeature, IFeatureDetail, IFeatureLineage } from "../models/model"; +import { DataSource, Feature, FeatureLineage, UserRole, Role } from "../models/model"; +import mockUserRole from "./mock/userrole.json"; -const API_ENDPOINT = "https://feathr-api-test.azurewebsites.net"; -const purview = "feathrazuretest3-purview1" +const API_ENDPOINT = process.env.REACT_APP_API_ENDPOINT + "/api/v1"; const token = "mockAppServiceKey"; export const fetchDataSources = async (project: string) => { return Axios - .get(`${ API_ENDPOINT }/v1/purview/${ purview }/projects/${ project }/datasources?code=${ token }`, + .get(`${ API_ENDPOINT }/projects/${ project }/datasources?code=${ token }`, { headers: {} }) .then((response) => { return response.data; @@ -16,7 +16,7 @@ export const fetchDataSources = async (project: string) => { export const fetchProjects = async () => { return Axios - .get<[]>(`${ API_ENDPOINT }/v1/purview/${ purview }/projects?code=${ token }`, + .get<[]>(`${ API_ENDPOINT }/projects?code=${ token }`, { headers: {} }) @@ -27,34 +27,42 @@ export const fetchProjects = async () => { export const fetchFeatures = async (project: string, page: number, limit: number, keyword: string) => { return Axios - .get(`${ API_ENDPOINT }/v1/purview/${ purview }/projects/${ project }/features?code=${ token }`, + .get(`${ API_ENDPOINT }/projects/${ project }/features?code=${ token }`, { params: { 'keyword': keyword, 'page': page, 'limit': limit }, headers: {} }) .then((response) => { - return response.data.features; + return response.data; }) }; -export const fetchFeature = async (project: string, qualifiedName: string) => { +export const fetchFeature = async (project: string, featureId: string) => { return Axios - .get(`${ API_ENDPOINT }/v1/purview/${ purview }/features/${ qualifiedName }?code=${ token }`, {}) + .get(`${ API_ENDPOINT }/features/${ featureId }?code=${ token }`, {}) .then((response) => { - return response.data?.entity?.attributes; + return response.data; }) }; export const fetchProjectLineages = async (project: string) => { return Axios - .get(`${ API_ENDPOINT }/v1/purview/${ purview }/features/lineage/${ project }?code=${ token }`, {}) + .get(`${ API_ENDPOINT }/projects/${ project }?code=${ token }`, {}) + .then((response) => { + return response.data; + }) +}; + +export const fetchFeatureLineages = async (project: string) => { + return Axios + .get(`${ API_ENDPOINT }/features/lineage/${ project }?code=${ token }`, {}) .then((response) => { return response.data; }) }; // Following are place-holder code -export const createFeature = async (feature: IFeature) => { +export const createFeature = async (feature: Feature) => { return Axios .post(`${ API_ENDPOINT }/features`, feature, { @@ -67,8 +75,8 @@ export const createFeature = async (feature: IFeature) => { }); } -export const updateFeature = async (feature: IFeature, id: string) => { - feature.id = id; +export const updateFeature = async (feature: Feature, id: string) => { + feature.guid = id; return await Axios.put(`${ API_ENDPOINT }/features/${ id }`, feature, { headers: { "Content-Type": "application/json;" }, @@ -93,3 +101,41 @@ export const deleteFeature = async (qualifiedName: string) => { }); }; +export const listUserRole = async () => { + let data:UserRole[] = mockUserRole + return data +}; + +export const getUserRole = async (userName: string) => { + return await Axios + .get(`${ API_ENDPOINT }/user/${userName}/userroles?code=${ token }`, {}) + .then((response) => { + return response.data; + }) +} + +export const addUserRole = async (role: Role) => { + return await Axios + .post(`${ API_ENDPOINT }/user/${role.userName}/userroles/new`, role, + { + headers: { "Content-Type": "application/json;" }, + params: {}, + }).then((response) => { + return response; + }).catch((error) => { + return error.response; + }); +} + +export const deleteUserRole = async (role: Role) => { + return await Axios + .post(`${ API_ENDPOINT }/user/${role.userName}/userroles/delete`, role, + { + headers: { "Content-Type": "application/json;" }, + params: {}, + }).then((response) => { + return response; + }).catch((error) => { + return error.response; + }); +} diff --git a/ui/src/api/mock/userrole.json b/ui/src/api/mock/userrole.json new file mode 100644 index 000000000..20535b8ec --- /dev/null +++ b/ui/src/api/mock/userrole.json @@ -0,0 +1,63 @@ +[ + { + "id": 1, + "scope": "Global", + "userName": "edwinc@microsoft.com", + "roleName": "Admin", + "permissions": [ + "Read", + "Write", + "Management" + ], + "createReason": "Resource Owner", + "createTime": "2022/5/15" + }, + { + "id": 1, + "scope": "Global", + "userName": "yuqwe@microsoft.com", + "roleName": "Admin", + "permissions": [ + "Read", + "Write", + "Management" + ], + "createReason": "Test Purpose", + "createTime": "2022/5/16" + }, + { + "id": 2, + "scope": "Project A: Frontend Datasets", + "userName": "blairch@microsoft.com", + "roleName": "Producer", + "permissions": [ + "Read", + "Write" + ], + "createReason": "Project Owner", + "createTime": "2022/5/16" + }, + { + "id": 3, + "scope": "Project B: Backend Datasets", + "userName": "xuchen@microsoft.com", + "roleName": "Producer", + "permissions": [ + "Read", + "Write" + ], + "createReason": "Project Owner", + "createTime": "2022/5/16" + }, + { + "id": 4, + "scope": "Project B: Backend Datasets", + "userName": "yihgu@microsoft.com", + "roleName": "Consumer", + "permissions": [ + "Read" + ], + "createReason": "Data Engineering", + "createTime": "2022/5/17" + } +] \ No newline at end of file diff --git a/ui/src/app.tsx b/ui/src/app.tsx new file mode 100644 index 000000000..2a21eb373 --- /dev/null +++ b/ui/src/app.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { Layout } from "antd"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { Configuration, InteractionType, PublicClientApplication, } from "@azure/msal-browser"; +import { MsalAuthenticationTemplate, MsalProvider } from "@azure/msal-react"; +import Header from "./components/header/header"; +import SideMenu from "./components/sidemenu/siteMenu"; +import Features from "./pages/feature/features"; +import NewFeature from "./pages/feature/newFeature"; +import FeatureDetails from "./pages/feature/featureDetails"; +import DataSources from "./pages/dataSource/dataSources"; +import Jobs from "./pages/jobs/jobs"; +import Monitoring from "./pages/monitoring/monitoring"; +import LineageGraph from "./pages/feature/lineageGraph"; +import Management from "./pages/management/management"; +import RoleManagement from "./pages/management/roleManagement"; + +const queryClient = new QueryClient(); + +const msalConfig: Configuration = { + auth: { + clientId: process.env.REACT_APP_AAD_APP_CLIENT_ID, + authority: process.env.REACT_APP_AAD_APP_AUTHORITY, + redirectUri: window.location.origin, + }, +}; +const msalClient = new PublicClientApplication(msalConfig); +const App: React.FC = () => { + return ( + + + + + + + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + ); +}; + +export default App; diff --git a/ui/src/components/dataSourceList.tsx b/ui/src/components/dataSourceList.tsx index 466cbe865..33a2c47fc 100644 --- a/ui/src/components/dataSourceList.tsx +++ b/ui/src/components/dataSourceList.tsx @@ -1,13 +1,10 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory } from 'react-router-dom'; import { LoadingOutlined } from "@ant-design/icons"; import { Form, Select, Table } from "antd"; -import { DataSourceAttributes, IDataSource } from "../models/model"; +import { DataSourceAttributes, DataSource } from "../models/model"; import { fetchDataSources, fetchProjects } from "../api"; const DataSourceList: React.FC = () => { - const history = useHistory(); - useCallback((location) => history.push(location), [history]); const columns = [ { title:
Name
, @@ -80,7 +77,7 @@ const DataSourceList: React.FC = () => { ]; const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [tableData, setTableData] = useState(); + const [tableData, setTableData] = useState(); const [projects, setProjects] = useState([]); const [project, setProject] = useState(""); @@ -100,7 +97,7 @@ const DataSourceList: React.FC = () => { useEffect(() => { loadProjects(); - }, []) + }, [loadProjects]) const onProjectChange = async (value: string) => { setProject(value); diff --git a/ui/src/components/featureForm.tsx b/ui/src/components/featureForm.tsx index bca7ddac3..f418f4270 100644 --- a/ui/src/components/featureForm.tsx +++ b/ui/src/components/featureForm.tsx @@ -1,9 +1,9 @@ import React, { CSSProperties, useEffect, useState } from 'react'; +import { UpCircleOutlined } from '@ant-design/icons' import { BackTop, Button, Form, Input, message, Space } from 'antd'; +import { Navigate } from "react-router-dom"; import { createFeature, updateFeature } from '../api'; -import { Redirect } from 'react-router'; -import { UpCircleOutlined } from '@ant-design/icons' -import { FeatureAttributes, IFeature } from "../models/model"; +import { FeatureAttributes, Feature } from "../models/model"; type FeatureFormProps = { isNew: boolean; @@ -25,7 +25,7 @@ const FeatureForm: React.FC = ({ isNew, editMode, feature }) = const onClickSave = async () => { setCreateLoading(true); - const featureToSave: IFeature = form.getFieldsValue(); + const featureToSave: Feature = form.getFieldsValue(); if (isNew) { const resp = await createFeature(featureToSave); if (resp.status === 201) { @@ -84,7 +84,7 @@ const FeatureForm: React.FC = ({ isNew, editMode, feature }) = - { fireRedirect && () } + { fireRedirect && () } ); }; diff --git a/ui/src/components/featureList.tsx b/ui/src/components/featureList.tsx index 8d13fe985..8fddc225c 100644 --- a/ui/src/components/featureList.tsx +++ b/ui/src/components/featureList.tsx @@ -1,23 +1,22 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Link, useHistory } from 'react-router-dom'; +import { Link, useNavigate } from "react-router-dom"; import { DownOutlined, LoadingOutlined } from '@ant-design/icons'; import { Button, Dropdown, Input, Menu, message, Popconfirm, Select, Tooltip, Form, Table } from 'antd'; -import { IFeature } from "../models/model"; +import { Feature } from "../models/model"; import { deleteFeature, fetchProjects, fetchFeatures } from "../api"; const FeatureList: React.FC = () => { - const history = useHistory(); - const navigateTo = useCallback((location) => history.push(location), [history]); + const navigate = useNavigate(); const columns = [ { title:
Name
, - dataIndex: 'name', + dataIndex: 'displayText', key: 'name', width: 150, - render: (name: string, row: IFeature) => { + render: (name: string, row: Feature) => { return ( ) }, @@ -30,9 +29,9 @@ const FeatureList: React.FC = () => { } }, { - title:
Qualified Name
, - dataIndex: 'qualifiedName', - key: 'qualifiedName', + title:
Type
, + dataIndex: 'typeName', + key: 'type', align: 'center' as 'center', width: 190, onCell: () => { @@ -50,13 +49,13 @@ const FeatureList: React.FC = () => { key: 'action', align: 'center' as 'center', width: 120, - render: (name: string, row: IFeature) => ( + render: (name: string, row: Feature) => ( { return ( @@ -64,7 +63,7 @@ const FeatureList: React.FC = () => { placement="left" title="Are you sure to delete?" onConfirm={ () => { - onDelete(row.id) + onDelete(row.guid) } } > Delete @@ -84,7 +83,7 @@ const FeatureList: React.FC = () => { const defaultPage = 1; const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [tableData, setTableData] = useState(); + const [tableData, setTableData] = useState(); const [query, setQuery] = useState(""); const [projects, setProjects] = useState([]); const [project, setProject] = useState(""); diff --git a/ui/src/components/graph/graph.tsx b/ui/src/components/graph/graph.tsx new file mode 100644 index 000000000..166cf19ae --- /dev/null +++ b/ui/src/components/graph/graph.tsx @@ -0,0 +1,167 @@ +import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useState, } from 'react'; +import ReactFlow, { + ConnectionLineType, + Controls, + Edge, + Elements, + getIncomers, + getOutgoers, + isEdge, + isNode, + Node, + ReactFlowProvider +} from 'react-flow-renderer'; +import { useSearchParams } from 'react-router-dom'; +import LineageNode from "./graphNode"; +import { findNodeInElement, getLayoutedElements } from "./utils"; + +const nodeTypes = { + 'custom-node': LineageNode, +}; +type Props = { + data: Elements; + nodeId: string; +} +const Graph: React.FC = ({ data, nodeId }) => { + const [, setURLSearchParams] = useSearchParams(); + + const { layoutedElements, elementMapping } = getLayoutedElements(data); + const [elements, setElements] = useState(layoutedElements); + + useEffect(() => { + setElements(layoutedElements); + }, [data, nodeId]); + + // Reset all node highlight status + const resetHighlight = (): void => { + if (!elements || elements.length === 0) { + return; + } + + const values: Elements = []; + + for (let index = 0; index < elements.length; index++) { + const element = elements[index]; + + if (isNode(element)) { + values.push({ + ...element, + style: { + ...element.style, + opacity: 1, + }, + }); + } + if (isEdge(element)) { + values.push({ + ...element, + animated: false, + }); + } + } + + setElements(values); + }; + + // Highlight path of selected node, including all linked up and down stream nodes + const highlightPath = (node: Node, check: boolean): void => { + const checkElements = check ? layoutedElements : elements; + + const incomerIds = new Set([...getIncomers(node, checkElements).map((i) => i.id)]); + const outgoerIds = new Set([...getOutgoers(node, checkElements).map((o) => o.id)]); + + const values: Elements = []; + for (let index = 0; index < checkElements.length; index++) { + const element = checkElements[index]; + let highlight = false; + if (isNode(element)) { + highlight = element.id === node.id + || incomerIds.has(element.id) + || outgoerIds.has(element.id); + } else { + highlight = element.source === node.id || element.target === node.id; + const animated = incomerIds.has(element.source) + && (incomerIds.has(element.target) || node.id === element.target); + + highlight = highlight || animated; + } + + if (isNode(element)) { + values.push({ + ...element, + style: { + ...element.style, + opacity: highlight ? 1 : 0.25, + }, + data: { + ...element.data, + active: element.id === node.id, + }, + }); + } + if (isEdge(element)) { + values.push({ + ...element, + animated: highlight, + }); + } + } + + setElements(values); + }; + + useEffect(() => { + if (nodeId) { + const node = findNodeInElement(nodeId, layoutedElements); + if (node) { + resetHighlight(); + highlightPath(node, !!nodeId); + } + } + }, [nodeId]); + + // When panel is clicked, reset all highlighted path, and remove the nodeId query string in url path. + const onPaneClick = useCallback(() => { + resetHighlight(); + setURLSearchParams({}); + }, []); + + const onNodeDragStop = (_: ReactMouseEvent, node: Node) => { + const nodePosition = elementMapping[node.data?.id]; + const values: Elements = [ + ...elements, + ]; + values[nodePosition] = node; + + setElements(values); + }; + + return ( +
+ + { + if (isNode(element)) { + resetHighlight(); + highlightPath(element, false); + setURLSearchParams({ nodeId: element.data.id }); + } + } } + onNodeDragStop={ onNodeDragStop } + connectionLineType={ ConnectionLineType.SmoothStep } + nodeTypes={ nodeTypes } + > + + + +
+ ); +} + +export default Graph; diff --git a/ui/src/components/graph/graphNode.tsx b/ui/src/components/graph/graphNode.tsx new file mode 100644 index 000000000..24119b778 --- /dev/null +++ b/ui/src/components/graph/graphNode.tsx @@ -0,0 +1,45 @@ +import React, { FC, memo } from 'react'; +import { RightCircleOutlined } from "@ant-design/icons"; +import { Handle, NodeProps, Position } from 'react-flow-renderer'; +import { useNavigate, useParams } from "react-router-dom"; + +type Params = { + project: string; +} +const GraphNode: FC = (props: NodeProps) => { + const navigate = useNavigate(); + const { project } = useParams(); + + const { data: { title, subtitle, featureId, borderColor, active } } = props; + + const nodeColorStyle = { + border: `2px solid ${ borderColor }`, + }; + + const onNodeIconClick = () => { + navigate(`/projects/${ project }/features/${ featureId }`) + }; + + return ( +
+
+ +
+
{ title } + { active && () } +
+
+ { subtitle } +
+
+ + +
+
+ ); +}; + +export default memo(GraphNode); diff --git a/ui/src/components/graph/utils.ts b/ui/src/components/graph/utils.ts new file mode 100644 index 000000000..d8fcf67d6 --- /dev/null +++ b/ui/src/components/graph/utils.ts @@ -0,0 +1,122 @@ +import dagre from 'dagre'; +import { ArrowHeadType, Edge, Elements, isNode, Node, Position, } from 'react-flow-renderer'; + +const DEFAULT_WIDTH = 172; +const DEFAULT_HEIGHT = 36; + +type getLayoutElementsRet = { + layoutedElements: Elements, + elementMapping: Record, +}; + +const getLayoutedElements = (elements: Elements, direction = 'LR'): getLayoutElementsRet => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + dagreGraph.setGraph({ rankdir: direction }); + + const isHorizontal = direction === 'LR'; + + for (let index = 0; index < elements.length; index++) { + const element: Node | Edge = elements[index]; + if (isNode(element)) { + dagreGraph.setNode(element.id, { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }); + } else { + dagreGraph.setEdge(element.source, element.target); + } + } + + dagre.layout(dagreGraph); + + const newElements = []; + const elementsObj: Record = {}; + + for (let index = 0; index < elements.length; index++) { + const element = elements[index] as Node; + + if (isNode(element)) { + elementsObj[element.data?.id] = index; + + const nodeWithPosition = dagreGraph.node(element.id); + element.targetPosition = isHorizontal ? Position.Left : Position.Top; + element.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; + + element.position = { + x: nodeWithPosition.x - DEFAULT_WIDTH / 2, + y: nodeWithPosition.y - DEFAULT_HEIGHT / 2, + }; + } + + newElements.push(element); + } + + return { + layoutedElements: newElements, + elementMapping: elementsObj, + }; +}; + +const featureTypeColors: Record = { + feathr_source_v1: 'hsl(315, 100%, 50%)', + feathr_anchor_v1: 'hsl(270, 100%, 50%)', + feathr_anchor_feature_v1: 'hsl(225, 100%, 50%)', + feathr_derived_feature_v1: 'hsl(135, 100%, 50%)' +}; + +const generateNode = ({ + nodeId, index, + currentNode +// eslint-disable-next-line @typescript-eslint/no-explicit-any + }: any): any => ({ + key: nodeId, + id: index?.toString(), + type: 'custom-node', + label: currentNode.displayText, + shape: 'box', + color: { + background: featureTypeColors[currentNode.typeName], + }, + data: { + id: nodeId, + title: currentNode.displayText, + subtitle: currentNode.typeName, + featureId: currentNode.guid, + borderColor: featureTypeColors[currentNode.typeName], + }, +}); + +type GenerateEdgeProps = { + obj: Record, + from: string; + to: string +} + +const generateEdge = ({ obj, from, to }: GenerateEdgeProps): Edge => { + const source = obj?.[from]; + const target = obj?.[to]; + + const id = `e${ source }-${ target }`; + return ({ + id, + source, + target, + arrowHeadType: ArrowHeadType.ArrowClosed, + }); +}; + +export { + generateEdge, + generateNode, + getLayoutedElements, +}; + +export const findNodeInElement = (nodeId: string | null, elements: Elements): Node | null => { + if (nodeId) { + const node = elements.find((element) => isNode(element) && element.data.id === nodeId); + return node as Node; + } + return null; +}; diff --git a/ui/src/components/lineage/layouting.css b/ui/src/components/lineage/layouting.css deleted file mode 100644 index e6d369e06..000000000 --- a/ui/src/components/lineage/layouting.css +++ /dev/null @@ -1,12 +0,0 @@ -.layoutflow { - flex-grow: 1; - position: relative; - height: 800px; -} - -.layoutflow .controls { - position: absolute; - right: 10px; - top: 10px; - z-index: 10; -} diff --git a/ui/src/components/lineage/lineage.tsx b/ui/src/components/lineage/lineage.tsx deleted file mode 100644 index d7ddae07a..000000000 --- a/ui/src/components/lineage/lineage.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useCallback } from 'react'; -import ReactFlow, { - Node, Edge, - ReactFlowProvider, - addEdge, - Controls, - Connection, - CoordinateExtent, - Position, - useNodesState, - useEdgesState, -} from 'react-flow-renderer'; -import dagre from 'dagre'; - -import './layouting.css'; - -const dagreGraph = new dagre.graphlib.Graph(); -dagreGraph.setDefaultEdgeLabel(() => ({})); - -const nodeExtent: CoordinateExtent = [ - [0, 0], - [1000, 1000], -]; - -type LineageProps = { - lineageNodes: Node[]; - lineageEdges: Edge[]; -}; - -const Lineage: React.FC = ({ lineageNodes, lineageEdges }) => { - const [nodes, setNodes, onNodesChange] = useNodesState(lineageNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(lineageEdges); - - const onConnect = useCallback((connection: Connection) => { - setEdges((eds) => addEdge(connection, eds)); - }, [setEdges]); - - const onLayout = (direction: string) => { - const isHorizontal = direction === 'LR'; - dagreGraph.setGraph({ rankdir: direction }); - - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 150, height: 50 }); - }); - - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target); - }); - - dagre.layout(dagreGraph); - - const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id); - node.targetPosition = isHorizontal ? Position.Left : Position.Top; - node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; - node.position = { x: nodeWithPosition.x + Math.random() / 1000, y: nodeWithPosition.y }; - - return node; - }); - - setNodes(layoutedNodes); - }; - - return ( -
- - onLayout('LR') } - onNodesChange={ onNodesChange } - onEdgesChange={ onEdgesChange } - > - - -
- - -
-
-
- ); -}; - -export default Lineage; diff --git a/ui/src/components/roleManagementForm.tsx b/ui/src/components/roleManagementForm.tsx new file mode 100644 index 000000000..fa8a6a4ac --- /dev/null +++ b/ui/src/components/roleManagementForm.tsx @@ -0,0 +1,88 @@ +import React, { CSSProperties, useEffect, useState } from 'react'; +import { BackTop, Button, Form, Input, Select, Space } from 'antd'; +import { Navigate } from "react-router-dom"; +import { addUserRole} from '../api'; +import { UpCircleOutlined } from '@ant-design/icons' +import { Role, UserRole } from "../models/model"; + +type RoleManagementFormProps = { + isNew: boolean; + editMode: boolean; + userRole?: UserRole; +}; + +const Admin = "Admin" +const Producer = "Producer" +const Consumer = "Consumer" + +const RoleManagementForm: React.FC = ({ editMode, userRole }) => { + const [fireRedirect] = useState(false); + const [createLoading, setCreateLoading] = useState(false); + + const [form] = Form.useForm(); + const { Option } = Select; + + useEffect(() => { + if (userRole !== undefined) { + form.setFieldsValue(userRole); + } + }, [userRole, form]); + + const onClickSave = async () => { + setCreateLoading(true); + const roleForm: Role = form.getFieldsValue(); + await addUserRole(roleForm); + setCreateLoading(false); + } + + const styling: CSSProperties = { width: "92%" } + return ( + <> +
+ + + + + + + + + + + + + + + + + + +
+ { fireRedirect && () } + + ); +}; + +export default RoleManagementForm diff --git a/ui/src/components/sidemenu/siteMenu.tsx b/ui/src/components/sidemenu/siteMenu.tsx index 6f688ecfd..d99638756 100644 --- a/ui/src/components/sidemenu/siteMenu.tsx +++ b/ui/src/components/sidemenu/siteMenu.tsx @@ -1,12 +1,9 @@ -import React from 'react'; import { Layout, Menu } from 'antd'; -import { CopyOutlined, DatabaseOutlined, EyeOutlined, RocketOutlined } from '@ant-design/icons'; -import { withRouter } from 'react-router'; +import { CopyOutlined, DatabaseOutlined, EyeOutlined, RocketOutlined} from '@ant-design/icons'; import { Link } from 'react-router-dom'; const { Sider } = Layout; - -const SideMenu = withRouter(({ history }) => { +const SideMenu = () => { return (
@@ -17,7 +14,6 @@ const SideMenu = withRouter(({ history }) => { mode="inline" defaultSelectedKeys={ ['/'] } defaultOpenKeys={ ['/'] } - selectedKeys={ [history.location.pathname] } > }> Data Sources @@ -34,6 +30,6 @@ const SideMenu = withRouter(({ history }) => {
); -}); +}; export default SideMenu; diff --git a/ui/src/components/userRoles.tsx b/ui/src/components/userRoles.tsx new file mode 100644 index 000000000..2fe18f005 --- /dev/null +++ b/ui/src/components/userRoles.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Modal, PageHeader, Row, Space, Table, Tag } from "antd"; +import { UserRole } from "../models/model"; +import { listUserRole } from "../api"; + +const UserRoles: React.FC = () => { + const navigate = useNavigate(); + const [visible, setVisible] = React.useState(false); + const [confirmLoading, setConfirmLoading] = React.useState(false); + const [modalText, setModalText] = React.useState('Content of the modal'); + + const showModal = () => { + setVisible(true); + setModalText("This Role Assignment will be deleted."); + }; + const handleOk = () => { + setModalText('The modal will be closed after two seconds'); + setConfirmLoading(true); + setTimeout(() => { + setVisible(false); + setConfirmLoading(false); + }, 2000); + }; + + const handleCancel = () => { + console.log('Clicked cancel button'); + setVisible(false); + }; + const columns = [ + { + title:
Scope
, + dataIndex: 'scope', + key: 'scope', + align: 'center' as 'center', + }, + { + title:
User
, + dataIndex: 'userName', + key: 'userName', + align: 'center' as 'center', + }, + { + title:
Role
, + dataIndex: 'roleName', + key: 'roleName', + align: 'center' as 'center', + }, + { + title:
Permissions
, + key: 'permissions', + dataIndex: 'permissions', + render: (tags: any[]) => ( + <> + {tags.map(tag => { + let color = tag.length > 5 ? 'red' : 'green'; + if (tag === 'Write') color = 'blue' + return ( + + {tag.toUpperCase()} + + ); + })} + + ), + }, + { + title:
Create Reason
, + dataIndex: 'createReason', + key: 'createReason', + align: 'center' as 'center', + }, + { + title:
Create Time
, + dataIndex: 'createTime', + key: 'createTime', + align: 'center' as 'center', + }, + { + title: 'Action', + key: 'action', + render: () => ( + + + +

{modalText}

+
+
+ ), + }, + ]; + const [page, setPage] = useState(1); + const [, setLoading] = useState(false); + const [tableData, setTableData] = useState(); + + const fetchData = useCallback(async () => { + setLoading(true); + const result = await listUserRole(); + console.log(result); + setPage(page); + setTableData(result); + setLoading(false); + }, [page]) + + const onClickRoleAssign = () => { + navigate('/role-management'); + return; + } + + useEffect(() => { + fetchData() + }, [fetchData]) + + return ( +
+ + +
+ <> +

+ Below is the mock data for now. Will connect with Management APIs. +

+ +
+
+
+ + + + ; + + ); +} + +export default UserRoles; diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 8f0ea99a6..5b2d5e60f 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -1,12 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import Routes from './router/routes'; +import App from "./app"; import 'antd/dist/antd.min.css'; import './index.less'; +import "./site.css" ReactDOM.render( - + , document.getElementById('root')); diff --git a/ui/src/models/model.ts b/ui/src/models/model.ts index 235855fc1..c89c31b26 100644 --- a/ui/src/models/model.ts +++ b/ui/src/models/model.ts @@ -1,45 +1,29 @@ -export interface Features { - features: IFeature[]; -} - -export interface IFeature { +export interface Feature { id: string; - name: string; - qualifiedName: string; - description: string; + guid: string; status: string; - featureType: string; - dataSource: string; - owners: string; -} - -export interface IFeatureDetail { - entity: IFeatureEntity; -} - -export interface IFeatureEntity { + displayText: string; + typeName: string; attributes: FeatureAttributes; } export interface FeatureAttributes { qualifiedName: string; name: string; - type: string; + type: FeatureType; transformation: FeatureTransformation; key: FeatureKey[]; window: string; - input_anchor_features: InputAnchorFeatures[]; - input_derived_features: InputDerivedFeatures[] -} - -export interface InputAnchorFeatures { - uniqueAttributes: FeatureAttributes; + _input_anchor_features: Feature[]; + _input_derived_features: Feature[] } -export interface InputDerivedFeatures { - uniqueAttributes: FeatureAttributes; +export interface FeatureType { + type: string, + tensor_category: string, + dimension_type: string[], + val_type: string } - export interface FeatureTransformation { transform_expr: string, filter: string, @@ -58,7 +42,7 @@ export interface FeatureKey { key_column_type: string } -export interface IDataSource { +export interface DataSource { attributes: DataSourceAttributes; } @@ -69,8 +53,25 @@ export interface DataSourceAttributes { } -export interface IFeatureLineage { +export interface FeatureLineage { guidEntityMap: any; relations: any; } +export interface UserRole { + id: number; + scope: string; + userName: string; + roleName: string; + createTime: string; + createReason: string; + deleteTime?: any; + deleteReason?: any; +} + +export interface Role { + scope: string; + userName: string; + roleName: string; + reason: string; +} diff --git a/ui/src/pages/feature/featureDetails.tsx b/ui/src/pages/feature/featureDetails.tsx index fef6f7f86..c4fc32314 100644 --- a/ui/src/pages/feature/featureDetails.tsx +++ b/ui/src/pages/feature/featureDetails.tsx @@ -1,34 +1,33 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { Alert, Button, Card, Col, Modal, Row, Space, Spin } from 'antd'; import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'; -import { useHistory, useParams } from 'react-router'; +import { useNavigate, useParams } from "react-router-dom"; import { QueryStatus, useQuery } from "react-query"; -import { deleteFeature, fetchFeature } from '../../api'; import { AxiosError } from 'axios'; -import { FeatureAttributes, InputAnchorFeatures } from "../../models/model"; +import { deleteFeature, fetchFeature } from '../../api'; +import { Feature } from "../../models/model"; const { confirm } = Modal; type Props = {}; type Params = { project: string; - qualifiedName: string; + featureId: string; } const FeatureDetails: React.FC = () => { - const { project, qualifiedName } = useParams(); + const { project, featureId } = useParams() as Params; + const navigate = useNavigate(); const loadingIcon = ; - const history = useHistory(); - const navigateTo = useCallback((location) => history.push(location), [history]); const { status, error, data - } = useQuery(['feature', qualifiedName], () => fetchFeature(project, qualifiedName)); + } = useQuery(['featureId', featureId], () => fetchFeature(project, featureId)); const openLineageWindow = () => { - const lineageUrl = `/projects/${ project }/features/${ qualifiedName }/lineage`; - window.open(lineageUrl); + const lineageUrl = `/projects/${ project }/lineage`; + navigate(lineageUrl); } const onClickDeleteFeature = () => { @@ -40,8 +39,8 @@ const FeatureDetails: React.FC = () => { title: 'Are you sure you want to delete this feature?', icon: , async onOk() { - await deleteFeature(qualifiedName); - history.push('/features'); + await deleteFeature(featureId); + navigate('/features'); }, onCancel() { console.log('Cancel clicked'); @@ -63,68 +62,70 @@ const FeatureDetails: React.FC = () => { ) } - const renderInputFeatureList = (features: InputAnchorFeatures[]) => { - return ( -
    - { features.map((_) => ( - - )) } -
- ); - } - const renderFeature = (feature: FeatureAttributes): JSX.Element => { + const renderFeature = (feature: Feature): JSX.Element => { return (
- { feature?.key && feature.key.length > 0 && + { feature.attributes.key && feature.attributes.key.length > 0 &&
-

full_name: { feature.key[0].full_name }

-

key_column: { feature.key[0].key_column }

-

description: { feature.key[0].description }

-

key_column_alias: { feature.key[0].key_column_alias }

-

key_column_type: { feature.key[0].key_column_type }

+

full_name: { feature.attributes.key[0].full_name }

+

key_column: { feature.attributes.key[0].key_column }

+

description: { feature.attributes.key[0].description }

+

key_column_alias: { feature.attributes.key[0].key_column_alias }

+

key_column_type: { feature.attributes.key[0].key_column_type }

} - { feature?.type && + { feature.attributes.type && - { feature.type } +

dimension_type: { feature.attributes.type.dimension_type }

+

tensor_category: { feature.attributes.type.tensor_category }

+

type: { feature.attributes.type.type }

+

val_type: { feature.attributes.type.val_type }

} - { feature?.transformation && + { feature.attributes.transformation && -

transform_expr: { feature.transformation.transform_expr ?? "N/A" }

-

filter: { feature.transformation.filter ?? "N/A" }

-

agg_func: { feature.transformation.agg_func ?? "N/A" }

-

limit: { feature.transformation.limit ?? "N/A" }

-

group_by: { feature.transformation.group_by ?? "N/A" }

-

window: { feature.transformation.window ?? "N/A" }

-

def_expr: { feature.transformation.def_expr ?? "N/A" }

+

transform_expr: { feature.attributes.transformation.transform_expr ?? "N/A" }

+

filter: { feature.attributes.transformation.filter ?? "N/A" }

+

agg_func: { feature.attributes.transformation.agg_func ?? "N/A" }

+

limit: { feature.attributes.transformation.limit ?? "N/A" }

+

group_by: { feature.attributes.transformation.group_by ?? "N/A" }

+

window: { feature.attributes.transformation.window ?? "N/A" }

+

def_expr: { feature.attributes.transformation.def_expr ?? "N/A" }

} - { feature?.input_anchor_features && feature?.input_anchor_features.length > 0 && + { feature.attributes._input_anchor_features && feature.attributes._input_anchor_features.length > 0 && - { renderInputFeatureList(feature.input_anchor_features) } + { + feature.attributes._input_anchor_features.map((feature) => + ) + } } - { feature?.input_derived_features && feature?.input_derived_features.length > 0 && + { feature.attributes._input_derived_features && feature.attributes._input_derived_features.length > 0 && - { renderInputFeatureList(feature.input_derived_features) } + { + feature.attributes._input_derived_features.map((feature) => + ) + } } @@ -172,7 +173,7 @@ const FeatureDetails: React.FC = () => { ); } else { return ( - + { renderCommandButtons() } { renderFeature(data) } @@ -182,11 +183,9 @@ const FeatureDetails: React.FC = () => { } return ( - <> -
- { render(status) } -
- +
+ { render(status) } +
); }; diff --git a/ui/src/pages/feature/featureLineage.tsx b/ui/src/pages/feature/featureLineage.tsx deleted file mode 100644 index 765c56e11..000000000 --- a/ui/src/pages/feature/featureLineage.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useCallback } from 'react'; -import { Alert, Card, Spin } from 'antd'; -import { useHistory, useParams } from 'react-router'; -import { QueryStatus, useQuery } from "react-query"; -import { LoadingOutlined } from '@ant-design/icons'; -import { AxiosError } from 'axios'; -import { Edge, Node, XYPosition } from "react-flow-renderer"; -import { fetchProjectLineages } from '../../api'; -import { IFeatureLineage } from "../../models/model"; -import Lineage from "../../components/lineage/lineage"; - -type Props = {}; -type Params = { - project: string; - qualifiedName: string; -} - -const FeatureDetails: React.FC = () => { - const { project, qualifiedName } = useParams(); - const loadingIcon = ; - const history = useHistory(); - useCallback((location) => history.push(location), [history]); - const { - status, - error, - data - } = useQuery(['feature', qualifiedName], () => fetchProjectLineages(project)); - - const render = (status: QueryStatus): JSX.Element => { - switch (status) { - case "error": - return ( - - - - ); - case "idle": - return ( - - - - ); - case "loading": - return ( - - - - ); - case "success": - if (data === undefined) { - return ( - - - - ); - } else { - const position: XYPosition = { x: 0, y: 0 }; - const nodes = Object.values(data.guidEntityMap).map((entity: any) => { - return { - id: entity.guid, - type: "", - data: { label: entity.displayText }, - position: position - } as Node - }); - const edges = data.relations.map((relation: any) => { - return { - id: relation.relationshipId, - source: relation.fromEntityId, - target: relation.toEntityId - } as Edge - }); - return ( - - - - ); - } - } - } - - return ( - <> -
- { render(status) } -
- - ); -}; - -export default FeatureDetails; diff --git a/ui/src/pages/feature/features.tsx b/ui/src/pages/feature/features.tsx index 02d161029..15d011832 100644 --- a/ui/src/pages/feature/features.tsx +++ b/ui/src/pages/feature/features.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { Button, Card, Space } from 'antd'; -import { useHistory } from 'react-router'; +import { useNavigate } from "react-router-dom"; import FeatureList from "../../components/featureList"; type Props = {}; const Features: React.FC = () => { - const history = useHistory(); + const navigate = useNavigate(); const onCreateFeatureClick = () => { - history.push('/new-feature'); + navigate('/new-feature'); }; return ( diff --git a/ui/src/pages/feature/lineageGraph.tsx b/ui/src/pages/feature/lineageGraph.tsx new file mode 100644 index 000000000..3cfcf5452 --- /dev/null +++ b/ui/src/pages/feature/lineageGraph.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Radio, Spin } from 'antd'; +import { useParams, useSearchParams } from "react-router-dom"; +import { Elements } from 'react-flow-renderer'; +import Graph from "../../components/graph/graph"; +import { generateEdge, generateNode } from "../../components/graph/utils"; +import { fetchProjectLineages } from "../../api"; +import { FeatureLineage } from "../../models/model"; +import { LoadingOutlined } from "@ant-design/icons"; + +type Params = { + project: string; +} +const LineageGraph: React.FC = () => { + const { project } = useParams() as Params; + const [searchParams] = useSearchParams(); + const nodeId = searchParams.get('nodeId') as string; + + const [lineageData, setLineageData] = useState({ guidEntityMap: null, relations: null }); + const [loading, setLoading] = useState(false); + const [elements, SetElements] = useState([]); + const [featureType, setFeatureType] = useState("all_nodes"); + + // Fetch lineage data from server side, invoked immediately after component is mounted + useEffect(() => { + const fetchLineageData = async () => { + const data = await fetchProjectLineages(project); + setLineageData(data); + setLoading(false); + }; + + fetchLineageData(); + }, [project]); + + // Generate graph data on client side, invoked after graphData or featureType is changed + useEffect(() => { + const generateGraphData = async () => { + if (lineageData.guidEntityMap === null && lineageData.relations === null) { + return; + } + + const elements: Elements = []; + const elementObj: Record = {}; + + for (let index = 0; index < Object.values(lineageData.guidEntityMap).length; index++) { + const currentNode: any = Object.values(lineageData.guidEntityMap)[index]; + + if (currentNode.typeName === "feathr_workspace_v1") { + continue; // Open issue: should project node get displayed as well? + } + + const nodeId = currentNode.guid; + + // If toggled feature type exists, skip other types + if (featureType && featureType !== "all_nodes" && currentNode.typeName !== featureType) { + continue; + } + + const node = generateNode({ + index, + nodeId, + currentNode + }); + + elementObj[nodeId] = index?.toString(); + + elements.push(node); + } + + for (let index = 0; index < lineageData.relations.length; index++) { + const { fromEntityId: from, toEntityId: to, relationshipType } = lineageData.relations[index]; + const edge = generateEdge({ obj: elementObj, from, to }); + if (edge?.source && edge?.target) { + // Currently, API returns all relationships, filter out Contains, Consumes, etc + if (relationshipType === "Produces") { + elements.push(edge); + } + } + } + + SetElements(elements); + }; + + generateGraphData(); + }, [lineageData, featureType]) + + const toggleFeatureType = (type: string) => { + setFeatureType((prevType: string | null) => { + if (prevType === type) { + return null; + } + return type; + }); + }; + + return ( + +
+ toggleFeatureType(e.target.value) }> + All Features + Source + Anchor + Anchor Feature + Derived Feature + +
+
+ { loading + ? } /> + : } +
+
+ ); +} + +export default LineageGraph; diff --git a/ui/src/pages/management/management.tsx b/ui/src/pages/management/management.tsx new file mode 100644 index 000000000..3788fd84c --- /dev/null +++ b/ui/src/pages/management/management.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Card } from 'antd'; +import UserRoles from '../../components/userRoles'; + +type Props = {}; + +const Management: React.FC = () => { + return ( + <> +
+ + + +
+ + ); +}; + +export default Management; \ No newline at end of file diff --git a/ui/src/pages/management/roleManagement.tsx b/ui/src/pages/management/roleManagement.tsx new file mode 100644 index 000000000..a2261c0b2 --- /dev/null +++ b/ui/src/pages/management/roleManagement.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Card } from 'antd'; +import RoleManagementForm from '../../components/roleManagementForm'; + +type Props = {}; + +const RoleManagement: React.FC = () => { + return ( +
+ + + +
+ ); +}; + +export default RoleManagement; \ No newline at end of file diff --git a/ui/src/react-app-env.d.ts b/ui/src/react-app-env.d.ts index 585499d3f..b816657d6 100644 --- a/ui/src/react-app-env.d.ts +++ b/ui/src/react-app-env.d.ts @@ -7,6 +7,7 @@ declare global { PWD: string; REACT_APP_AAD_APP_CLIENT_ID: string; REACT_APP_AAD_APP_AUTHORITY: string; + REACT_APP_API_ENDPOINT: string; } } } diff --git a/ui/src/router/routes.tsx b/ui/src/router/routes.tsx deleted file mode 100644 index 4337f9f04..000000000 --- a/ui/src/router/routes.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { Suspense } from "react"; -import { BrowserRouter, Route, Switch, withRouter } from "react-router-dom"; -import { Layout } from "antd"; -import { QueryClient, QueryClientProvider } from "react-query"; -import { Configuration, InteractionType, PublicClientApplication, } from "@azure/msal-browser"; -import { MsalAuthenticationTemplate, MsalProvider } from "@azure/msal-react"; -import Header from "../components/header/header"; -import SideMenu from "../components/sidemenu/siteMenu"; -import Features from "../pages/feature/features"; -import NewFeature from "../pages/feature/newFeature"; -import FeatureDetails from "../pages/feature/featureDetails"; -import DataSources from "../pages/dataSource/dataSources"; -import FeatureLineage from "../pages/feature/featureLineage"; -import Jobs from "../pages/jobs/jobs"; -import Monitoring from "../pages/monitoring/monitoring"; - -type Props = {}; -const queryClient = new QueryClient(); - -const msalConfig: Configuration = { - auth: { - clientId: process.env.REACT_APP_AAD_APP_CLIENT_ID, - authority: process.env.REACT_APP_AAD_APP_AUTHORITY, - redirectUri: window.location.origin, - }, -}; -const pca = new PublicClientApplication(msalConfig); -const Routes: React.FC = () => { - return ( - - - - - - - -
- - }> - - - - - - - - {/* {publicRoutes} */ } - {/* */ } - - - - - - - - - ); -}; - -export default Routes; diff --git a/ui/src/site.css b/ui/src/site.css new file mode 100644 index 000000000..7a314f46f --- /dev/null +++ b/ui/src/site.css @@ -0,0 +1,40 @@ +.lineage-graph { + margin-top: 1.5rem; +} + +.lineage-node-active { + overflow: hidden; + border-radius: 0.25rem; + border-width: 2px; + border-style: solid; + --tw-border-opacity: 1; + border-color: rgba(57, 35, 150, var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgba(57, 35, 150, var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgba(255, 255, 255, var(--tw-text-opacity)); + opacity: 1; +} + +.lineage-node-box { + padding: 4px 12px 7px; +} + +.lineage-node-title { + font-size: 14px; + font-weight: 700; +} + +.lineage-node-subtitle { + font-size: 10px; + font-style: italic; + text-overflow: ellipsis; + max-width: 135px; + overflow: hidden; + white-space: nowrap; +} + +.lineage-navigate { + padding: 4px 12px 7px; +} +