diff --git a/src/rest-server/src/controllers/user.js b/src/rest-server/src/controllers/user.js index 357845e1d0..cd91803214 100644 --- a/src/rest-server/src/controllers/user.js +++ b/src/rest-server/src/controllers/user.js @@ -85,7 +85,7 @@ const updateUserVc = (req, res, next) => { }; /** - * Update user virtual clusters. + * Get user list */ const getUserList = (req, res, next) => { userModel.getUserList((err, userList) => { @@ -96,6 +96,27 @@ const getUserList = (req, res, next) => { }); }; +/** + * Get user info + */ +const getUserInfo = (req, res, next) => { + const username = req.params.username; + if (req.user.admin || req.user.username === username) { + userModel.getUserList((err, userList) => { + if (err) { + return next(createError.unknown(err)); + } + const item = userList.find((x) => x.username === username); + if (!item) { + return next(createError('Bad Request', 'NoUserError', `User ${username} is not found.`)); + } + return res.status(200).json(item); + }); + } else { + next(createError('Forbidden', 'ForbiddenUserError', `Non-admin is not allowed to do this operation.`)); + } +}; + /** * Update user Github PAT. */ @@ -118,4 +139,4 @@ const updateUserGithubPAT =(req, res, next) => { }; // module exports -module.exports = {update, remove, updateUserVc, getUserList, updateUserGithubPAT}; +module.exports = {update, remove, updateUserVc, getUserInfo, getUserList, updateUserGithubPAT}; diff --git a/src/rest-server/src/routes/user.js b/src/rest-server/src/routes/user.js index b1eccb7caf..cc650cbc9d 100644 --- a/src/rest-server/src/routes/user.js +++ b/src/rest-server/src/routes/user.js @@ -34,6 +34,10 @@ router.route('/') /** Get /api/v1/user - Get user info list */ .get(userController.getUserList); + +router.route('/:username/') + .get(token.check, userController.getUserInfo); + router.route('/:username/virtualClusters') .put(token.check, param.validate(userConfig.userVcUpdateInputSchema), userController.updateUserVc); diff --git a/src/webportal/.eslintrc.js b/src/webportal/.eslintrc.js index a58073a258..b5d043fb14 100644 --- a/src/webportal/.eslintrc.js +++ b/src/webportal/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { }, "overrides": [ { - "files": ["**/*.jsx", "src/app/job/job-view/fabric/**/*.js"], + "files": ["**/*.jsx", "src/app/job/job-view/fabric/**/*.js", "src/app/components/**/*.js", "src/app/home/**/*.js"], "parser": "babel-eslint" } ] diff --git a/src/webportal/config/webpack.common.js b/src/webportal/config/webpack.common.js index 8b3cd1a424..57c9fb6426 100644 --- a/src/webportal/config/webpack.common.js +++ b/src/webportal/config/webpack.common.js @@ -32,6 +32,7 @@ const version = require('../package.json').version; const FABRIC_DIR = [ path.resolve(__dirname, '../src/app/job/job-view/fabric'), path.resolve(__dirname, '../src/app/home'), + path.resolve(__dirname, '../src/app/components'), path.resolve(__dirname, '../node_modules/tachyons'), ]; @@ -53,6 +54,7 @@ function generateHtml(opt) { const config = (env, argv) => ({ entry: { 'index': './src/app/home/index.jsx', + 'home': './src/app/home/home.jsx', 'layout': './src/app/layout/layout.component.js', 'register': './src/app/user/user-register/user-register.component.js', 'userView': './src/app/user/user-view/user-view.component.js', @@ -130,35 +132,83 @@ const config = (env, argv) => ({ test: /\.(css|scss)$/, include: FABRIC_DIR, use: [ - argv.mode === 'production' ? MiniCssExtractPlugin.loader : 'style-loader', + argv.mode === 'production' + ? MiniCssExtractPlugin.loader + : { + loader: 'style-loader', + options: { + sourceMap: true, + }, + }, { loader: 'css-loader', options: { url: true, - minimize: true, sourceMap: true, + importLoaders: 2, modules: true, camelCase: true, localIdentName: '[name]-[local]--[hash:base64:5]', }, }, - 'sass-loader', + { + loader: 'postcss-loader', + options: { + sourceMap: true, + ident: 'postcss', + plugins: (loader) => [ + require('postcss-import')({root: loader.resourcePath}), + require('autoprefixer')(), + require('cssnano')(), + ], + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, ], }, { test: /\.(css|scss)$/, exclude: FABRIC_DIR, use: [ - argv.mode === 'production' ? MiniCssExtractPlugin.loader : 'style-loader', + argv.mode === 'production' + ? MiniCssExtractPlugin.loader + : { + loader: 'style-loader', + options: { + sourceMap: true, + }, + }, { loader: 'css-loader', options: { url: true, - minimize: true, + sourceMap: true, + importLoaders: 2, + }, + }, + { + loader: 'postcss-loader', + options: { + sourceMap: true, + ident: 'postcss2', + plugins: (loader) => [ + require('postcss-import')({root: loader.resourcePath}), + require('autoprefixer')(), + require('cssnano')(), + ], + }, + }, + { + loader: 'sass-loader', + options: { sourceMap: true, }, }, - 'sass-loader', ], }, { @@ -221,6 +271,10 @@ const config = (env, argv) => ({ chunks: ['index'], template: './src/app/home/index.ejs', }), + generateHtml({ + filename: 'home.html', + chunks: ['layout', 'home'], + }), generateHtml({ filename: 'register.html', chunks: ['layout', 'register'], diff --git a/src/webportal/package.json b/src/webportal/package.json index b8539d0a81..0aec72730d 100644 --- a/src/webportal/package.json +++ b/src/webportal/package.json @@ -25,17 +25,21 @@ "@webcomponents/custom-elements": "^1.2.1", "admin-lte": "~2.4.2", "app-root-path": "~2.0.1", + "autoprefixer": "^9.5.1", "babel-eslint": "^10.0.1", "babel-loader": "^8.0.5", "babel-plugin-lodash": "^3.3.4", "blueimp-file-upload": "~9.22.1", "bootstrap": "~3.4.0", + "chart.js": "^2.8.0", + "chartjs-plugin-datalabels": "^0.6.0", "classnames": "^2.2.6", "compression": "~1.7.1", "cookie-parser": "~1.4.3", "copy-webpack-plugin": "~5.0.1", "core-js": "^3.0.1", - "css-loader": "~0.28.7", + "css-loader": "^2.1.1", + "cssnano": "^4.1.10", "datatables.net-buttons-bs": "^1.5.4", "datatables.net-plugins": "~1.10.15", "datatables.net-responsive-bs": "^2.2.3", @@ -72,6 +76,8 @@ "node-sass": "~4.7.2", "office-ui-fabric-react": "^6.143.0", "papaparse": "^4.6.3", + "postcss-import": "^12.0.1", + "postcss-loader": "^3.0.0", "prop-types": "^15.7.2", "raw-loader": "~0.5.1", "react": "^16.8.3", @@ -86,7 +92,7 @@ "strip-json-comments": "^2.0.1", "style-loader": "~0.19.0", "styled-components": "^4.2.0", - "tachyons": "^4.11.1", + "tachyons-sass": "^4.9.5", "terser-webpack-plugin": "^1.2.3", "util": "~0.10.3", "webpack": "~4.29.6", diff --git a/src/webportal/src/app/components/loading.jsx b/src/webportal/src/app/components/loading.jsx new file mode 100644 index 0000000000..42d3e7cd74 --- /dev/null +++ b/src/webportal/src/app/components/loading.jsx @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {FontClassNames, ColorClassNames} from '@uifabric/styling'; +import c from 'classnames'; +import {isEqual, isNil} from 'lodash'; +import {Spinner, SpinnerSize} from 'office-ui-fabric-react/lib/Spinner'; +import React, {useLayoutEffect, useState} from 'react'; + +import t from './tachyons.scss'; + +import loadingGif from '../../assets/img/loading.gif'; + +export const Loading = () => ( +
+
+ +
+
+); + +// min-height issue hack +// https://stackoverflow.com/questions/8468066/child-inside-parent-with-min-height-100-not-inheriting-height +export const SpinnerLoading = () => { + const [style, setStyle] = useState({}); + useLayoutEffect(() => { + function layout() { + const contentWrapper = document.getElementById('content-wrapper'); + if (isNil(contentWrapper)) { + return; + } + const pt = window.getComputedStyle(contentWrapper).paddingTop; + const rect = contentWrapper.getBoundingClientRect(); + const nextStyle = { + paddingTop: pt, + top: rect.top, + left: rect.left, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }; + if (!isEqual(nextStyle, style)) { + setStyle(nextStyle); + } + } + layout(); + window.addEventListener('resize', layout); + return () => { + window.removeEventListener('resize', layout); + }; + }); + + return ( +
+
+ +
Loading...
+
+
+ ); +}; + +export const MaskSpinnerLoading = () => ( +
+
+
+ +
Loading...
+
+
+
+); diff --git a/src/webportal/src/app/components/status-badge.jsx b/src/webportal/src/app/components/status-badge.jsx new file mode 100644 index 0000000000..6e4a4f170a --- /dev/null +++ b/src/webportal/src/app/components/status-badge.jsx @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {FontClassNames, mergeStyles} from '@uifabric/styling'; +import c from 'classnames'; +import {isEmpty} from 'lodash'; +import {Icon} from 'office-ui-fabric-react/lib/Icon'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import t from './tachyons.scss'; + +import {statusColorMapping} from './theme'; + +export const Badge = ({children, className}) => ( +
+ {children} +
+); + +Badge.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + icons: PropTypes.array, +}; + +export const IconBadge = ({children, className, icons}) => ( + +
+ { + icons &&
+ { + icons.map((iconName, idx) => ( + + )) + } +
+ } +
{children}
+
+
+); + +IconBadge.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + icons: PropTypes.array, +}; + +const bgYellow = mergeStyles({backgroundColor: statusColorMapping.waiting}); +const bgRed = mergeStyles({backgroundColor: statusColorMapping.failed}); +const bgBlue = mergeStyles({backgroundColor: statusColorMapping.running}); +const bgGreen = mergeStyles({backgroundColor: statusColorMapping.succeeded}); +const bgGray = mergeStyles({backgroundColor: statusColorMapping.unknown}); + +export const SucceededBadge = ({children}) => ( + + {children} + +); + +SucceededBadge.propTypes = { + children: PropTypes.node, +}; + +export const PrimaryBadge = ({children}) => ( + + {children} + +); + +PrimaryBadge.propTypes = { + children: PropTypes.node, +}; + +export const WaitingBadge = ({children}) => ( + + {children} + +); + +WaitingBadge.propTypes = { + children: PropTypes.node, +}; + +export const FailedBadge = ({children}) => ( + + {children} + +); + +FailedBadge.propTypes = { + children: PropTypes.node, +}; + +export const StoppedBadge = ({children}) => ( + + {children} + +); + +StoppedBadge.propTypes = { + children: PropTypes.node, +}; + +export const UnknownBadge = ({children}) => ( + + {children || 'Unknown'} + +); + +UnknownBadge.propTypes = { + children: PropTypes.node, +}; + +export const StatusBadge = ({status}) => { + switch (status) { + case 'Running': + return {status}; + case 'Stopping': + case 'Waiting': + return {status}; + case 'Failed': + return {status}; + case 'Succeeded': + return {status}; + case 'Stopped': + return {status}; + case 'Unknown': + return {status}; + default: + return {status}; + } +}; + +StatusBadge.propTypes = { + status: PropTypes.oneOf(['Running', 'Stopping', 'Waiting', 'Failed', 'Succeeded', 'Stopped', 'Unknown']), +}; + + +export default StatusBadge; diff --git a/src/webportal/src/app/components/tachyons.scss b/src/webportal/src/app/components/tachyons.scss new file mode 100644 index 0000000000..1e39c39e9b --- /dev/null +++ b/src/webportal/src/app/components/tachyons.scss @@ -0,0 +1,66 @@ +/* tachyons without normalize.css */ + +// Variables +// Importing here will allow you to override any variables in the modules + +@import '~tachyons-sass/scss/_variables'; + +// Debugging +@import '~tachyons-sass/scss/_debug-children'; +@import '~tachyons-sass/scss/_debug-grid'; + +// Uncomment out the line below to help debug layout issues +// @import '~tachyons-sass/scss/_debug'; + +// Modules +@import '~tachyons-sass/scss/_box-sizing'; +@import '~tachyons-sass/scss/_aspect-ratios'; +@import '~tachyons-sass/scss/_images'; +@import '~tachyons-sass/scss/_background-size'; +@import '~tachyons-sass/scss/_background-position'; +@import '~tachyons-sass/scss/_outlines'; +@import '~tachyons-sass/scss/_borders'; +@import '~tachyons-sass/scss/_border-colors'; +@import '~tachyons-sass/scss/_border-radius'; +@import '~tachyons-sass/scss/_border-style'; +@import '~tachyons-sass/scss/_border-widths'; +@import '~tachyons-sass/scss/_box-shadow'; +@import '~tachyons-sass/scss/_code'; +@import '~tachyons-sass/scss/_coordinates'; +@import '~tachyons-sass/scss/_clears'; +@import '~tachyons-sass/scss/_flexbox'; +@import '~tachyons-sass/scss/_display'; +@import '~tachyons-sass/scss/_floats'; +@import '~tachyons-sass/scss/_font-family'; +@import '~tachyons-sass/scss/_font-style'; +@import '~tachyons-sass/scss/_font-weight'; +@import '~tachyons-sass/scss/_forms'; +@import '~tachyons-sass/scss/_heights'; +@import '~tachyons-sass/scss/_letter-spacing'; +@import '~tachyons-sass/scss/_line-height'; +@import '~tachyons-sass/scss/_links'; +@import '~tachyons-sass/scss/_lists'; +@import '~tachyons-sass/scss/_max-widths'; +@import '~tachyons-sass/scss/_widths'; +@import '~tachyons-sass/scss/_overflow'; +@import '~tachyons-sass/scss/_position'; +@import '~tachyons-sass/scss/_opacity'; +@import '~tachyons-sass/scss/_rotations'; +@import '~tachyons-sass/scss/_skins'; +@import '~tachyons-sass/scss/_skins-pseudo'; +@import '~tachyons-sass/scss/_spacing'; +@import '~tachyons-sass/scss/_negative-margins'; +@import '~tachyons-sass/scss/_tables'; +@import '~tachyons-sass/scss/_text-decoration'; +@import '~tachyons-sass/scss/_text-align'; +@import '~tachyons-sass/scss/_text-transform'; +@import '~tachyons-sass/scss/_type-scale'; +@import '~tachyons-sass/scss/_typography'; +@import '~tachyons-sass/scss/_utilities'; +@import '~tachyons-sass/scss/_visibility'; +@import '~tachyons-sass/scss/_white-space'; +@import '~tachyons-sass/scss/_vertical-align'; +@import '~tachyons-sass/scss/_hovers'; +@import '~tachyons-sass/scss/_z-index'; +@import '~tachyons-sass/scss/_nested'; +@import '~tachyons-sass/scss/_styles'; diff --git a/src/webportal/src/app/components/theme.js b/src/webportal/src/app/components/theme.js new file mode 100644 index 0000000000..fc7195f22f --- /dev/null +++ b/src/webportal/src/app/components/theme.js @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {loadTheme, FontWeights} from '@uifabric/styling'; + +export function initTheme() { + loadTheme({ + spacing: { + s2: '4px', + s1: '8px', + m: '16px', + l1: '20px', + l2: '32px', + l3: '64px', + }, + fonts: { + xLarge: { + fontSize: 20, + fontWeight: FontWeights.semibold, + }, + large: { + fontSize: 17, + fontWeight: FontWeights.regular, + }, + }, + }); +} + +export const statusColorMapping = { + waiting: '#fcd116', + failed: '#eb1123', + running: '#0071bc', + succeeded: '#7fba00', + unknown: '#b1b5b8', +}; diff --git a/src/webportal/src/app/components/util/job.js b/src/webportal/src/app/components/util/job.js new file mode 100644 index 0000000000..007196503c --- /dev/null +++ b/src/webportal/src/app/components/util/job.js @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {get, isNil} from 'lodash'; +import {Interval, DateTime} from 'luxon'; + +export function getHumanizedJobStateString(job) { + let hjss = ''; + if (job.state === 'JOB_NOT_FOUND') { + hjss = 'N/A'; + } else if (job.state === 'WAITING') { + if (job.executionType === 'STOP') { + hjss = 'Stopping'; + } else { + hjss = 'Waiting'; + } + } else if (job.state === 'RUNNING') { + if (job.executionType === 'STOP') { + hjss = 'Stopping'; + } else { + hjss = 'Running'; + } + } else if (job.state === 'SUCCEEDED') { + hjss = 'Succeeded'; + } else if (job.state === 'FAILED') { + hjss = 'Failed'; + } else if (job.state === 'STOPPED') { + hjss = 'Stopped'; + } else { + hjss = 'Unknown'; + } + return hjss; +} + +export function getJobDuration(jobInfo) { + const start = get(jobInfo, 'createdTime') && DateTime.fromMillis(jobInfo.createdTime); + const end = get(jobInfo, 'completedTime') && DateTime.fromMillis(jobInfo.completedTime); + if (start) { + return Interval.fromDateTimes(start, end || DateTime.utc()).toDuration(['days', 'hours', 'minutes', 'seconds']); + } else { + return null; + } +} + +export function getJobDurationString(jobInfo) { + const dur = getJobDuration(jobInfo); + if (!isNil(dur)) { + if (dur.days > 0) { + return dur.toFormat(`d'd' h'h' m'm' s's'`); + } else if (dur.hours > 0) { + return dur.toFormat(`h'h' m'm' s's'`); + } else if (dur.minutes > 0) { + return dur.toFormat(`m'm' s's'`); + } else { + return dur.toFormat(`s's'`); + } + } else { + return 'N/A'; + } +} + +export function getJobModifiedTime(job) { + const modified = job.completedTime || job.createdTime; + if (!isNil(modified)) { + return DateTime.fromMillis(modified); + } else { + return null; + } +} + +export function getJobModifiedTimeString(job) { + const time = getJobModifiedTime(job); + if (!isNil(time)) { + return time.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS); + } else { + return 'N/A'; + } +} diff --git a/src/webportal/src/app/dashboard/dashboard.component.js b/src/webportal/src/app/dashboard/dashboard.component.js index 3a72e40743..448a5f4588 100644 --- a/src/webportal/src/app/dashboard/dashboard.component.js +++ b/src/webportal/src/app/dashboard/dashboard.component.js @@ -33,6 +33,7 @@ window.onresize = function(envent) { }; $(document).ready(function() { + document.getElementById('sidebar-menu--dashboard').classList.add('active'); resizeContentWrapper(); $('#content-wrapper').html(dashboardHtml); }); diff --git a/src/webportal/src/app/home/home.jsx b/src/webportal/src/app/home/home.jsx new file mode 100644 index 0000000000..2ae2b87af6 --- /dev/null +++ b/src/webportal/src/app/home/home.jsx @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import 'whatwg-fetch'; + +import c from 'classnames'; +import {isEmpty} from 'lodash'; +import {initializeIcons, Stack, getTheme} from 'office-ui-fabric-react'; +import React, {useState, useEffect} from 'react'; +import ReactDOM from 'react-dom'; +import styled from 'styled-components'; + +import JobStatus from './home/job-status'; +import VirtualClusterList from './home/virtual-cluster-list'; +import GpuChart from './home/gpu-chart'; +import {listJobs, getTotalGpu, getUserInfo, listVirtualClusters, getAvailableGpuPerNode} from './home/conn'; +import RecentJobList from './home/recent-job-list'; +import {SpinnerLoading} from '../components/loading'; +import {initTheme} from '../components/theme'; + +import t from '../components/tachyons.scss'; + +initTheme(); +initializeIcons(); + +const Home = () => { + const [loading, setLoading] = useState(true); + const [jobs, setJobs] = useState(null); + const [userInfo, setUserInfo] = useState(null); + const [virtualClusters, setVirtualClusters] = useState(null); + const [totalGpu, setTotalGpu] = useState(null); + const [gpuPerNode, setGpuPerNode] = useState(null); + + useEffect(() => { + if (!isEmpty(cookies.get('user'))) { + Promise.all([ + listJobs().then(setJobs), + getUserInfo().then(setUserInfo), + listVirtualClusters().then(setVirtualClusters), + getTotalGpu().then(setTotalGpu), + getAvailableGpuPerNode().then(setGpuPerNode), + ]).then(() => setLoading(false)).catch(alert); + } else { + // layout.component.js will redirect user to index page. + } + }, []); + + if (loading) { + return ; + } else { + const {spacing} = getTheme(); + + const ResponsiveGap = styled.div` { + height: 0; + width: ${spacing.l2}; + @media screen and (max-width: 64em) { + height: ${spacing.l2}; + width: 0; + } + }`; + + const ResponsiveItem = styled.div` + width: 33%; + height: auto; + @media screen and (max-width: 64em) { + width: 100%; + height: 32rem; + } + `; + + return ( + + {/* top */} + +
+ + + + + + + + + + + +
+
+ {/* recent jobs */} + + + +
+ ); + } +}; + +const contentWrapper = document.getElementById('content-wrapper'); + +ReactDOM.render(, contentWrapper); + +document.getElementById('sidebar-menu--home').classList.add('active'); diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/loading.jsx b/src/webportal/src/app/home/home/card.jsx similarity index 56% rename from src/webportal/src/app/job/job-view/fabric/job-detail/components/loading.jsx rename to src/webportal/src/app/home/home/card.jsx index 4f25419aaf..0e7f23a50b 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/loading.jsx +++ b/src/webportal/src/app/home/home/card.jsx @@ -15,30 +15,23 @@ // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -import {FontClassNames} from '@uifabric/styling'; -import c from 'classnames'; -import {Spinner, SpinnerSize} from 'office-ui-fabric-react/lib/Spinner'; +import PropTypes from 'prop-types'; +import {ColorClassNames, Stack} from 'office-ui-fabric-react'; import React from 'react'; -import t from '../../tachyons.css'; - -import loadingGif from '../../../../../../assets/img/loading.gif'; - -export const Loading = () => ( -
-
- -
-
+const Card = ({className, style, children}) => ( + + {children} + ); -export const SpinnerLoading = () => ( -
-
-
- -
Loading...
-
-
-
-); +Card.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node, +}; + +export default Card; diff --git a/src/webportal/src/app/home/home/conn.js b/src/webportal/src/app/home/home/conn.js new file mode 100644 index 0000000000..6a7927cf20 --- /dev/null +++ b/src/webportal/src/app/home/home/conn.js @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {get, isNil} from 'lodash'; +import querystring from 'querystring'; + +import config from '../../config/webportal.config'; + +const username = cookies.get('user'); +const token = cookies.get('token'); + +export async function listJobs() { + const res = await fetch(`${config.restServerUri}/api/v1/jobs?${querystring.stringify({username})}`); + + const json = await res.json(); + if (res.ok) { + return json; + } else { + throw new Error(json.message); + } +} + +export async function getUserInfo() { + const res = await fetch(`${config.restServerUri}/api/v1/user/${username}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const json = await res.json(); + if (res.ok) { + return json; + } else { + throw new Error(json.message); + } +} + +export async function listVirtualClusters() { + const res = await fetch(`${config.restServerUri}/api/v1/virtual-clusters`); + + const json = await res.json(); + if (res.ok) { + return json; + } else { + throw new Error(json.message); + } +} + +export async function getTotalGpu() { + const res = await fetch(`${config.prometheusUri}/api/v1/query?query=sum(yarn_node_gpu_total)`); + + if (res.ok) { + const json = await res.json(); + const data = get(json, 'data.result[0].value[1]'); + if (!isNil(data)) { + return parseInt(data, 10); + } else { + throw new Error('Invalid total gpu response'); + } + } else { + const json = await res.json(); + throw new Error(json.error); + } +} + +export async function getAvailableGpuPerNode() { + const res = await fetch(`${config.prometheusUri}/api/v1/query?query=yarn_node_gpu_available`); + + if (res.ok) { + const json = await res.json(); + try { + const result = {}; + for (const x of json.data.result) { + const ip = x.metric.node_ip; + const count = parseInt(x.value[1], 10); + result[ip] = count; + } + return result; + } catch { + throw new Error('Invalid available gpu per node response'); + } + } else { + const json = await res.json(); + throw new Error(json.error); + } +} diff --git a/src/webportal/src/app/home/home/gpu-chart.jsx b/src/webportal/src/app/home/home/gpu-chart.jsx new file mode 100644 index 0000000000..8dae145090 --- /dev/null +++ b/src/webportal/src/app/home/home/gpu-chart.jsx @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import Chart from 'chart.js'; +import 'chartjs-plugin-datalabels'; // This plugin registers itself globally +import c from 'classnames'; +import {range} from 'lodash'; +import PropTypes from 'prop-types'; +import {Stack, FontClassNames} from 'office-ui-fabric-react'; +import React, {useEffect, useRef} from 'react'; + +import Card from './card'; +import {statusColorMapping} from '../../components/theme'; + +import t from '../../components/tachyons.scss'; + +const GpuChart = ({className, gpuPerNode}) => { + const maxVal = Math.max(...Object.values(gpuPerNode)); + const data = Array(maxVal + 1).fill(0); + for (const key of Object.keys(gpuPerNode)) { + data[gpuPerNode[key]] += 1; + } + + const chartRef = useRef(null); + + useEffect(() => { + new Chart(chartRef.current, { + type: 'bar', + data: { + labels: range(maxVal + 1), + datasets: [{ + backgroundColor: statusColorMapping.succeeded, + label: 'nodeCount', + data: data, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + legend: { + display: false, + }, + tooltips: { + enabled: false, + }, + scales: { + xAxes: [{ + scaleLabel: { + display: true, + labelString: '#GPU', + }, + gridLines: { + display: false, + }, + }], + yAxes: [{ + scaleLabel: { + display: true, + labelString: '#Node', + }, + ticks: { + max: Math.max(...data) * 1.2, + display: false, + }, + gridLines: { + display: false, + }, + }], + }, + plugins: { + datalabels: { + anchor: 'end', + align: 'end', + }, + }, + }, + }); + }); + + return ( + + + +
+ Available GPU nodes +
+
+ +
+ +
+
+
+
+ ); +}; + +GpuChart.propTypes = { + className: PropTypes.string, + gpuPerNode: PropTypes.object.isRequired, +}; + +export default GpuChart; diff --git a/src/webportal/src/app/home/home/job-status.jsx b/src/webportal/src/app/home/home/job-status.jsx new file mode 100644 index 0000000000..682a13cf2c --- /dev/null +++ b/src/webportal/src/app/home/home/job-status.jsx @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import c from 'classnames'; +import {isEmpty} from 'lodash'; +import PropTypes from 'prop-types'; +import querystring from 'querystring'; +import {Icon, Stack, FontClassNames, ColorClassNames, DefaultButton, getTheme} from 'office-ui-fabric-react'; +import React from 'react'; + +import Card from './card'; +import {getHumanizedJobStateString} from '../../components/util/job'; + +import t from '../../components/tachyons.scss'; + +const StatusItem = ({className, icon, name, count, link}) => { + const {spacing} = getTheme(); + return ( + + +
+
+ +
+
+
+ {name} +
+
+
+
+ {count} +
+
+
+
+ + + +
+ ); +}; + +StatusItem.propTypes = { + className: PropTypes.string, + icon: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + link: PropTypes.string.isRequired, +}; + +const JobStatus = ({className, jobs}) => { + let waiting = 0; + let running = 0; + let stopped = 0; + let failed = 0; + let succeeded = 0; + if (!isEmpty(jobs)) { + waiting = jobs.filter((x) => getHumanizedJobStateString(x) === 'Waiting').length; + running = jobs.filter((x) => ['Running', 'Stopping'].includes(getHumanizedJobStateString(x))).length; + stopped = jobs.filter((x) => getHumanizedJobStateString(x) === 'Stopped').length; + failed = jobs.filter((x) => getHumanizedJobStateString(x) === 'Failed').length; + succeeded = jobs.filter((x) => getHumanizedJobStateString(x) === 'Succeeded').length; + } + return ( + + + +
+ My job status +
+
+ + + + + + + + + +
+
+ ); +}; + +JobStatus.propTypes = { + className: PropTypes.string, + jobs: PropTypes.array, +}; + +export default JobStatus; diff --git a/src/webportal/src/app/home/home/recent-job-list.jsx b/src/webportal/src/app/home/home/recent-job-list.jsx new file mode 100644 index 0000000000..a8ece6a657 --- /dev/null +++ b/src/webportal/src/app/home/home/recent-job-list.jsx @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import c from 'classnames'; +import {isEmpty} from 'lodash'; +import {Link, PrimaryButton, DefaultButton, Stack, getTheme, FontClassNames, DetailsList, DetailsListLayoutMode, SelectionMode} from 'office-ui-fabric-react'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Card from './card'; +import {getJobDurationString, getJobModifiedTimeString, getHumanizedJobStateString, getJobModifiedTime} from '../../components/util/job'; + +import t from '../../components/tachyons.scss'; +import StatusBadge from '../../components/status-badge'; + +const Header = ({jobs}) => ( +
+
+ My rencent jobs +
+ {!isEmpty(jobs) && ( +
+ More +
+ )} +
+); + +Header.propTypes = { + jobs: PropTypes.array.isRequired, +}; + +const DummyContent = () => { + const {spacing} = getTheme(); + return ( +
+
+
+ No rencent resources to display +
+
+ {`As you visit jobs, they'll be listed in Recently used jobs for quick and easy access.`} +
+ + + + + + + + +
+
+ ); +}; + +const jobListColumns = [ + { + key: 'name', + minWidth: 200, + name: 'Name', + fieldName: 'name', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender(job) { + const {legacy, name, namespace, username} = job; + const href = legacy + ? `/job-detail.html?jobName=${name}` + : `/job-detail.html?username=${namespace || username}&jobName=${name}`; + return {name}; + }, + }, + { + key: 'modified', + minWidth: 150, + name: 'Date Modified', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender(job) { + return getJobModifiedTimeString(job); + }, + }, + { + key: 'duration', + minWidth: 120, + name: 'Duration', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + isResizable: true, + onRender(job) { + return getJobDurationString(job); + }, + }, + { + key: 'virtualCluster', + minWidth: 100, + name: 'Virtual Cluster', + fieldName: 'virtualCluster', + className: FontClassNames.mediumPlus, + headerClassName: FontClassNames.medium, + isResizable: true, + }, + { + key: 'status', + minWidth: 100, + name: 'Status', + headerClassName: FontClassNames.medium, + isResizable: true, + onRender(job) { + return ; + }, + }, +]; + +const Content = ({jobs}) => { + if (true && isEmpty(jobs)) { + return ; + } else { + const items = jobs + .slice() + .sort((a, b) => getJobModifiedTime(b) - getJobModifiedTime(a)) + .slice(0, 10); + return ( +
+ +
+ ); + } +}; + +const RecentJobList = ({className, jobs}) => { + return ( + + + +
+ + + + + + + ); +}; + +RecentJobList.propTypes = { + className: PropTypes.string, + jobs: PropTypes.array.isRequired, +}; + +export default RecentJobList; diff --git a/src/webportal/src/app/home/home/virtual-cluster-list.jsx b/src/webportal/src/app/home/home/virtual-cluster-list.jsx new file mode 100644 index 0000000000..90574694fc --- /dev/null +++ b/src/webportal/src/app/home/home/virtual-cluster-list.jsx @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import c from 'classnames'; +import PropTypes from 'prop-types'; +import {Stack, ColorClassNames, FontClassNames, PersonaCoin, getTheme} from 'office-ui-fabric-react'; +import React from 'react'; + +import Card from './card'; +import {statusColorMapping} from '../../components/theme'; + +import t from '../../components/tachyons.scss'; + +const VirtualClusterItem = ({name, info, totalGpu}) => { + const availableGpu = Math.floor(totalGpu * info.maxCapacity / 100) - info.resourcesUsed.GPUs; + const percentage = availableGpu / totalGpu; + let color; + if (availableGpu === 0) { + color = statusColorMapping.failed; + } else { + color = statusColorMapping.succeeded; + } + + const {spacing} = getTheme(); + + return ( + + + + + + + {/* vc item title */} + +
+ {name} +
+
+ {/* vc item status */} + +
+
+ {availableGpu} +
+
+ GPU Available +
+
+ {availableGpu === 0 + ?
+ : ( +
+
+
+
+ ) + } +
+
+
+
+
+
+ ); +}; + +VirtualClusterItem.propTypes = { + name: PropTypes.string.isRequired, + info: PropTypes.object.isRequired, + totalGpu: PropTypes.number.isRequired, +}; + +const VirtualCluster = ({className, userInfo, virtualClusters, totalGpu}) => { + const vcNames = userInfo.virtualCluster.split(','); + const {spacing} = getTheme(); + return ( + + + +
+ {`My virtual clusters (${vcNames.length})`} +
+
+ +
+ + {vcNames.map((name) => ( + + + + ))} + +
+
+
+
+ ); +}; + +VirtualCluster.propTypes = { + className: PropTypes.string, + userInfo: PropTypes.object.isRequired, + virtualClusters: PropTypes.object.isRequired, + totalGpu: PropTypes.number.isRequired, +}; + +export default VirtualCluster; diff --git a/src/webportal/src/app/home/index.ejs b/src/webportal/src/app/home/index.ejs index a41de1be77..ad151ac255 100644 --- a/src/webportal/src/app/home/index.ejs +++ b/src/webportal/src/app/home/index.ejs @@ -9,7 +9,6 @@
- \ No newline at end of file diff --git a/src/webportal/src/app/home/index.jsx b/src/webportal/src/app/home/index.jsx index 1b31ef74c3..ef70a120ff 100644 --- a/src/webportal/src/app/home/index.jsx +++ b/src/webportal/src/app/home/index.jsx @@ -19,20 +19,21 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import 'whatwg-fetch'; -import {FontClassNames, ColorClassNames, FontWeights} from '@uifabric/styling'; +import {FontClassNames} from '@uifabric/styling'; import c from 'classnames'; -import {Link, Modal, TextField, PrimaryButton, MessageBar, MessageBarType, initializeIcons} from 'office-ui-fabric-react'; -import PropTypes from 'prop-types'; -import React, {useRef, useState, useCallback} from 'react'; +import {initializeIcons} from 'office-ui-fabric-react'; +import React, {useState, useCallback} from 'react'; import ReactDOM from 'react-dom'; -import styled from 'styled-components'; +import Bottom from './index/bottom'; +import {login} from './index/conn'; +import Jumbotron from './index/jumbotron'; +import LoginModal from './index/login-modal'; import {checkToken} from '../user/user-auth/user-auth.component'; -import config from '../config/webportal.config'; -import t from '../../../node_modules/tachyons/css/tachyons.css'; +import t from 'tachyons-sass/tachyons.scss'; -const loginTarget = '/job-list.html'; +const loginTarget = '/home.html'; if (checkToken(false)) { window.location.replace(loginTarget); @@ -40,147 +41,16 @@ if (checkToken(false)) { initializeIcons(); -const JumbotronBackground = styled.div` - background-image: url('/assets/img/home-background.svg'); - background-repeat: repeat; - z-index: -2; - position: absolute; - width: 100%; - height: 100%; - &::before { - content: ""; - display: block; - z-index: -1; - position: absolute; - width: 100%; - height: 100%; - background: linear-gradient(to right, rgba(0, 113, 188, 1), rgba(0, 113, 188, 0.65) 70%, rgba(0, 113, 188, 0.07)); - } -`; - -const Jumbotron = ({showLoginModal}) => ( -
- -
-
- Platform for AI -
-
- Platform for AI is an open source platform that provides complete AI model training and resource management capabilities, it is easy to extend and supports on-premise, cloud and hybrid environments in various scale. -
-
-
- Sign in -
-
-
-
-); - -Jumbotron.propTypes = { - showLoginModal: PropTypes.func, -}; - -const Bottom = () => ( -
-
-
-
- Submit a hello-world job -
-
- With submitting a hello-world job, this section introduces more knowledge about job, so that you can write your own job configuration easily. -
-
- - Learn more - -
-
-
-
- Understand Job -
-
- The job of OpenPAI defines how to execute command(s) in specified environment(s). A job can be model training, other kinds of commands, or distributed on multiple servers. -
-
- - Learn more - -
-
-
-
- Use VS Code Extension to work with Jobs -
-
- OpenPAI Client is a VS Code extension to connect PAI clusters, submit AI jobs, and manage files on HDFS, etc. You need to install the extension in VS code before using it. -
-
- - Learn more - -
-
-); - -async function login(username, password, expiration = 7 * 24 * 60 * 60) { - const res = await fetch(`${config.restServerUri}/api/v1/token`, { - method: 'POST', - body: JSON.stringify({ - username, - password, - expiration, - }), - headers: { - 'content-type': 'application/json', - }, - }); - if (res.ok) { - const data = await res.json(); - if (data.error) { - throw new Error(data.message); - } else { - cookies.set('user', data.user, {expires: expiration}); - cookies.set('token', data.token, {expires: expiration}); - cookies.set('admin', data.admin, {expires: expiration}); - cookies.set('hasGitHubPAT', data.hasGitHubPAT, {expires: expiration}); - } - } else { - const data = await res.json(); - throw new Error(data.message); - } -} - -const Home = () => { +const Index = () => { const [loginModal, setLoginModal] = useState(false); const [error, setError] = useState(null); const [lock, setLock] = useState(false); - const usernameRef = useRef(null); - const passwordRef = useRef(null); const onLogin = useCallback( - (e) => { - e.preventDefault(); + (username, password) => { setLock(true); void login( - usernameRef.current.value, - passwordRef.current.value + username, + password ).then(() => { window.location.replace(loginTarget); }).catch((e) => { @@ -206,7 +76,7 @@ const Home = () => { ); return ( -
+
{/* top */}
@@ -224,46 +94,15 @@ const Home = () => {
{/* login modal */} - -
-
- -
-
- Sign in with your OpenPAI account -
- {error && ( - - {error} - - )} -
-
- -
-
- -
-
- -
-
-
-
+ onLogin={onLogin} + />
); }; -ReactDOM.render(, document.getElementById('content')); +ReactDOM.render(, document.getElementById('content')); diff --git a/src/webportal/src/app/home/index/bottom.jsx b/src/webportal/src/app/home/index/bottom.jsx new file mode 100644 index 0000000000..ed8fbfaa82 --- /dev/null +++ b/src/webportal/src/app/home/index/bottom.jsx @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {FontClassNames, FontWeights} from '@uifabric/styling'; +import c from 'classnames'; +import {Link} from 'office-ui-fabric-react'; +import React from 'react'; + +import t from 'tachyons-sass/tachyons.scss'; + +const Bottom = () => ( +
+
+
+
+ Submit a hello-world job +
+
+ With submitting a hello-world job, this section introduces more knowledge about job, so that you can write your own job configuration easily. +
+
+ + Learn more + +
+
+
+
+ Understand Job +
+
+ The job of OpenPAI defines how to execute command(s) in specified environment(s). A job can be model training, other kinds of commands, or distributed on multiple servers. +
+
+ + Learn more + +
+
+
+
+ Use VS Code Extension to work with Jobs +
+
+ OpenPAI Client is a VS Code extension to connect PAI clusters, submit AI jobs, and manage files on HDFS, etc. You need to install the extension in VS code before using it. +
+
+ + Learn more + +
+
+); + +export default Bottom; diff --git a/src/webportal/src/app/home/index/conn.js b/src/webportal/src/app/home/index/conn.js new file mode 100644 index 0000000000..97121f9fd1 --- /dev/null +++ b/src/webportal/src/app/home/index/conn.js @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import config from '../../config/webportal.config'; + +export async function login(username, password, expiration = 7 * 24 * 60 * 60) { + const res = await fetch(`${config.restServerUri}/api/v1/token`, { + method: 'POST', + body: JSON.stringify({ + username, + password, + expiration, + }), + headers: { + 'content-type': 'application/json', + }, + }); + if (res.ok) { + const data = await res.json(); + if (data.error) { + throw new Error(data.message); + } else { + cookies.set('user', data.user, {expires: expiration}); + cookies.set('token', data.token, {expires: expiration}); + cookies.set('admin', data.admin, {expires: expiration}); + cookies.set('hasGitHubPAT', data.hasGitHubPAT, {expires: expiration}); + } + } else { + const data = await res.json(); + throw new Error(data.message); + } +} diff --git a/src/webportal/src/app/home/index/jumbotron.jsx b/src/webportal/src/app/home/index/jumbotron.jsx new file mode 100644 index 0000000000..6a89d3c15c --- /dev/null +++ b/src/webportal/src/app/home/index/jumbotron.jsx @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {FontClassNames, ColorClassNames} from '@uifabric/styling'; +import c from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import styled from 'styled-components'; + +import t from 'tachyons-sass/tachyons.scss'; + +const JumbotronBackground = styled.div` + background-image: url('/assets/img/home-background.svg'); + background-repeat: repeat; + z-index: -2; + position: absolute; + width: 100%; + height: 100%; + &::before { + content: ""; + display: block; + z-index: -1; + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient(to right, rgba(0, 113, 188, 1), rgba(0, 113, 188, 0.65) 70%, rgba(0, 113, 188, 0.07)); + } +`; + +const Jumbotron = ({showLoginModal}) => ( +
+ +
+
+ Platform for AI +
+
+ Platform for AI is an open source platform that provides complete AI model training and resource management capabilities, it is easy to extend and supports on-premise, cloud and hybrid environments in various scale. +
+
+
+ Sign in +
+
+
+
+); + +Jumbotron.propTypes = { + showLoginModal: PropTypes.func, +}; + +export default Jumbotron; diff --git a/src/webportal/src/app/home/index/login-modal.jsx b/src/webportal/src/app/home/index/login-modal.jsx new file mode 100644 index 0000000000..4ba626da36 --- /dev/null +++ b/src/webportal/src/app/home/index/login-modal.jsx @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +// to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import {FontClassNames, FontWeights} from '@uifabric/styling'; +import c from 'classnames'; +import {Modal, TextField, PrimaryButton, MessageBar, MessageBarType} from 'office-ui-fabric-react'; +import PropTypes from 'prop-types'; +import React, {useRef, useCallback} from 'react'; + +import t from 'tachyons-sass/tachyons.scss'; + +const LoginModal = ({isOpen, lock, error, onDismiss, onLogin}) => { + const usernameRef = useRef(null); + const passwordRef = useRef(null); + const onSubmit = useCallback( + (e) => { + e.preventDefault(); + onLogin(usernameRef.current.value, passwordRef.current.value); + }, + [], + ); + return ( + +
+
+ +
+
+ Sign in with your OpenPAI account +
+ {error && ( + + {error} + + )} +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; + +LoginModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + lock: PropTypes.bool.isRequired, + error: PropTypes.string, + onDismiss: PropTypes.func.isRequired, + onLogin: PropTypes.func.isRequired, +}; + +export default LoginModal; diff --git a/src/webportal/src/app/job/job-view/fabric/JobList/index.jsx b/src/webportal/src/app/job/job-view/fabric/JobList/index.jsx index 9c1064103f..f09a338db8 100644 --- a/src/webportal/src/app/job/job-view/fabric/JobList/index.jsx +++ b/src/webportal/src/app/job/job-view/fabric/JobList/index.jsx @@ -18,7 +18,7 @@ import * as querystring from 'querystring'; import React, {useState, useMemo, useCallback, useEffect, useRef} from 'react'; -import {debounce} from 'lodash'; +import {debounce, isEmpty} from 'lodash'; import {initializeIcons} from 'office-ui-fabric-react/lib/Icons'; import {Fabric} from 'office-ui-fabric-react/lib/Fabric'; @@ -58,15 +58,25 @@ export default function JobList() { const [error, setError] = useState(null); const initialFilter = useMemo(() => { - const initialFilterUsers = (username && !admin) ? new Set([username]) : undefined; - let filter = new Filter(undefined, initialFilterUsers); - filter.load(); const query = querystring.parse(location.search.replace(/^\?/, '')); - if (query['vcName']) { - const {keyword, users, statuses} = filter; - filter = new Filter(keyword, users, new Set().add(query['vcName']), statuses); + if (['vcName', 'status', 'user'].some((x) => !isEmpty(query[x]))) { + const queryFilter = new Filter(); + if (query['vcName']) { + queryFilter.virtualClusters = new Set(query['vcName']); + } + if (query['status']) { + queryFilter.statuses = new Set([query['status']]); + } + if (query['user']) { + queryFilter.users = new Set([query['user']]); + } + setFilter(queryFilter); + } else { + const initialFilterUsers = (username && !admin) ? new Set([username]) : undefined; + let filter = new Filter(undefined, initialFilterUsers); + filter.load(); + return filter; } - return filter; }); const [filter, setFilter] = useState(initialFilter); const [ordering, setOrdering] = useState(new Ordering()); diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx index 922558c4cf..d48b16faf9 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail.jsx @@ -26,11 +26,11 @@ import ReactDOM from 'react-dom'; import {initializeIcons} from '@uifabric/icons'; import {FontClassNames} from '@uifabric/styling'; -import t from './tachyons.css'; +import t from '../../../components/tachyons.scss'; import Top from './job-detail/components/top'; import Summary from './job-detail/components/summary'; -import {SpinnerLoading} from './job-detail/components/loading'; +import {SpinnerLoading} from '../../../components/loading'; import TaskRole from './job-detail/components/task-role'; import {fetchJobConfig, fetchJobInfo, fetchSshInfo, stopJob, NotFoundError} from './job-detail/conn'; import {getHumanizedJobStateString, getTaskConfig, isJobV2} from './job-detail/util'; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/card.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/card.jsx index 8fc6b54e2f..6e153535cc 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/card.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/card.jsx @@ -19,7 +19,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; const Card = ({children, className, style}) => (
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-callout.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-callout.jsx index 994bcfca97..a996b2dd41 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-callout.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-callout.jsx @@ -22,7 +22,7 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; import {monacoHack} from './monaco-hack.scss'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; export default class MonacoCallout extends React.Component { constructor(props) { diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-modal.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-modal.jsx index 9d026a69dd..75aa59fb65 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-modal.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-modal.jsx @@ -24,7 +24,7 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; import {monacoHack} from './monaco-hack.scss'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; const MonacoModal = ({isOpen, onDismiss, title, monacoProps}) => (
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-panel.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-panel.jsx index c4098fbbfb..6c9c1b7277 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-panel.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/monaco-panel.jsx @@ -25,7 +25,7 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; import {monacoHack} from './monaco-hack.scss'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; export default class MonacoPanel extends React.Component { constructor(props) { diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/status-badge.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/status-badge.jsx index bc4ae598d7..1ee268a338 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/status-badge.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/status-badge.jsx @@ -22,9 +22,9 @@ import {Icon} from 'office-ui-fabric-react/lib/Icon'; import PropTypes from 'prop-types'; import React from 'react'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; -import {statusColorMapping} from '../util'; +import {statusColorMapping} from '../../../../../components/theme'; export const Badge = ({children, className}) => (
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx index a51f3072f6..c9a6ad84ae 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/summary.jsx @@ -26,7 +26,7 @@ import {MessageBar, MessageBarType} from 'office-ui-fabric-react/lib/MessageBar' import PropTypes from 'prop-types'; import React from 'react'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; import Card from './card'; import MonacoPanel from './monaco-panel'; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx index 0df56e8083..e36fb0baba 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role-container-list.jsx @@ -25,7 +25,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import localCss from './task-role-container-list.scss'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; import MonacoPanel from './monaco-panel'; import Timer from './timer'; diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx index 8727be6305..6d2b0bf229 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/task-role.jsx @@ -21,12 +21,12 @@ import {IconButton} from 'office-ui-fabric-react/lib/Button'; import PropTypes from 'prop-types'; import React from 'react'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; import Card from './card'; import MonacoCallout from './monaco-callout'; -import {statusColorMapping} from '../util'; import TaskRoleContainerList from './task-role-container-list'; +import {statusColorMapping} from '../../../../../components/theme'; export default class TaskRole extends React.Component { constructor(props) { diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx b/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx index 07944d7fc8..d28a77c243 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/components/top.jsx @@ -18,7 +18,7 @@ import React from 'react'; import {ActionButton} from 'office-ui-fabric-react/lib/Button'; -import t from '../../tachyons.css'; +import t from '../../../../../components/tachyons.scss'; const Top = () => (
diff --git a/src/webportal/src/app/job/job-view/fabric/job-detail/util.js b/src/webportal/src/app/job/job-view/fabric/job-detail/util.js index 5de6769056..0bc3cef6c1 100644 --- a/src/webportal/src/app/job/job-view/fabric/job-detail/util.js +++ b/src/webportal/src/app/job/job-view/fabric/job-detail/util.js @@ -106,11 +106,3 @@ export function getTaskConfig(jobConfig, name) { return null; } } - -export const statusColorMapping = { - waiting: '#fcd116', - failed: '#eb1123', - running: '#0071bc', - succeeded: '#7fba00', - unknown: '#b1b5b8', -}; diff --git a/src/webportal/src/app/job/job-view/fabric/tachyons.css b/src/webportal/src/app/job/job-view/fabric/tachyons.css deleted file mode 100644 index c1cc376046..0000000000 --- a/src/webportal/src/app/job/job-view/fabric/tachyons.css +++ /dev/null @@ -1,62 +0,0 @@ -/* Modules */ -@import '../../../../../node_modules/tachyons/src/_box-sizing'; -@import '../../../../../node_modules/tachyons/src/_aspect-ratios'; -@import '../../../../../node_modules/tachyons/src/_images'; -@import '../../../../../node_modules/tachyons/src/_background-size'; -@import '../../../../../node_modules/tachyons/src/_background-position'; -@import '../../../../../node_modules/tachyons/src/_outlines'; -@import '../../../../../node_modules/tachyons/src/_borders'; -@import '../../../../../node_modules/tachyons/src/_border-colors'; -@import '../../../../../node_modules/tachyons/src/_border-radius'; -@import '../../../../../node_modules/tachyons/src/_border-style'; -@import '../../../../../node_modules/tachyons/src/_border-widths'; -@import '../../../../../node_modules/tachyons/src/_box-shadow'; -@import '../../../../../node_modules/tachyons/src/_code'; -@import '../../../../../node_modules/tachyons/src/_coordinates'; -@import '../../../../../node_modules/tachyons/src/_clears'; -@import '../../../../../node_modules/tachyons/src/_display'; -@import '../../../../../node_modules/tachyons/src/_flexbox'; -@import '../../../../../node_modules/tachyons/src/_floats'; -@import '../../../../../node_modules/tachyons/src/_font-family'; -@import '../../../../../node_modules/tachyons/src/_font-style'; -@import '../../../../../node_modules/tachyons/src/_font-weight'; -@import '../../../../../node_modules/tachyons/src/_forms'; -@import '../../../../../node_modules/tachyons/src/_heights'; -@import '../../../../../node_modules/tachyons/src/_letter-spacing'; -@import '../../../../../node_modules/tachyons/src/_line-height'; -@import '../../../../../node_modules/tachyons/src/_links'; -@import '../../../../../node_modules/tachyons/src/_lists'; -@import '../../../../../node_modules/tachyons/src/_max-widths'; -@import '../../../../../node_modules/tachyons/src/_widths'; -@import '../../../../../node_modules/tachyons/src/_overflow'; -@import '../../../../../node_modules/tachyons/src/_position'; -@import '../../../../../node_modules/tachyons/src/_opacity'; -@import '../../../../../node_modules/tachyons/src/_rotations'; -@import '../../../../../node_modules/tachyons/src/_skins'; -@import '../../../../../node_modules/tachyons/src/_skins-pseudo'; -@import '../../../../../node_modules/tachyons/src/_spacing'; -@import '../../../../../node_modules/tachyons/src/_negative-margins'; -@import '../../../../../node_modules/tachyons/src/_tables'; -@import '../../../../../node_modules/tachyons/src/_text-decoration'; -@import '../../../../../node_modules/tachyons/src/_text-align'; -@import '../../../../../node_modules/tachyons/src/_text-transform'; -@import '../../../../../node_modules/tachyons/src/_type-scale'; -@import '../../../../../node_modules/tachyons/src/_typography'; -@import '../../../../../node_modules/tachyons/src/_utilities'; -@import '../../../../../node_modules/tachyons/src/_visibility'; -@import '../../../../../node_modules/tachyons/src/_white-space'; -@import '../../../../../node_modules/tachyons/src/_vertical-align'; -@import '../../../../../node_modules/tachyons/src/_hovers'; -@import '../../../../../node_modules/tachyons/src/_z-index'; -@import '../../../../../node_modules/tachyons/src/_nested'; -@import '../../../../../node_modules/tachyons/src/_styles'; - -/* Variables */ -/* Importing here will allow you to override any variables in the modules */ -@import '../../../../../node_modules/tachyons/src/_colors'; -@import '../../../../../node_modules/tachyons/src/_media-queries'; - -/* Debugging */ -@import '../../../../../node_modules/tachyons/src/_debug-children'; -@import '../../../../../node_modules/tachyons/src/_debug-grid'; - diff --git a/src/webportal/src/app/layout/layout.component.ejs b/src/webportal/src/app/layout/layout.component.ejs index 694982a892..92235f20f3 100644 --- a/src/webportal/src/app/layout/layout.component.ejs +++ b/src/webportal/src/app/layout/layout.component.ejs @@ -39,6 +39,12 @@