From e2d4e2c8d098bc329078f0b1dce32e87879d0dea Mon Sep 17 00:00:00 2001 From: Ian Goldstein Date: Fri, 21 Oct 2016 15:55:19 -0700 Subject: [PATCH 1/5] note on contributing docs images (#65) --- docs/docs/95-contributing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/95-contributing.md b/docs/docs/95-contributing.md index 4a60a8fb86..bfee33c2a9 100644 --- a/docs/docs/95-contributing.md +++ b/docs/docs/95-contributing.md @@ -41,6 +41,7 @@ Use Github to submit issues and enhancement requests. - Code coverage tooling and % TBD - All tests pass! - Docs! + - If you are adding images to the docs, please keep image width < 800px - Style! - Run `gulp lint` to make sure your contribution follows our project's style guidelines (based on (https://github.com/airbnb/javascript)). From 4aa36abcfaec9d79a8a01d6208f3b1a4941a3903 Mon Sep 17 00:00:00 2001 From: Shriram Date: Fri, 21 Oct 2016 16:08:02 -0700 Subject: [PATCH 2/5] enable socketio clients to communicate with client using only the protocols set in the config (#39) * specify which client-to-server socket.io protocol(s) to use based on either an env var or a query param from perspectives page (which overrides the env var) --- README.md | 2 +- index.js | 1 + view/loadView.js | 1 + view/perspective/app.js | 30 ++++++++++++++++++++++++++++-- view/perspective/perspective.pug | 3 +++ viewConfig.js | 4 ++++ 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b1d644f8f7..bd5ca46495 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ After installing the server, you can run ```redis-cli``` to issue commands to re - Run ```git push heroku :master``` which will push to Heroku and start up a dyno. - Run ```heroku open``` and view the app running in Heroku - Run ```heroku run bash``` then run ```mocha``` to execute the test suite - +- If you are running the app in more than one dyno, you will need to force the client to communicate with the server only using websockets. To do so, set the config variable SOCKETIO_TRANSPORT_PROTOCOL to websocket or run ```heroku config:set SOCKETIO_TRANSPORT_PROTOCOL=websocket``` If you are running on Heroku and you want to use Google Analytics, store your tracking id in a Heroku config variable called `GOOGLE_ANALYTICS_ID`. ## Using Trace (by RisingStack) for application performance monitoring diff --git a/index.js b/index.js index c6df4cd61f..c81d7afb35 100644 --- a/index.js +++ b/index.js @@ -54,6 +54,7 @@ function start() { // eslint-disable-line max-statements const app = express(); const httpServer = require('http').Server(app); + const io = require('socket.io')(httpServer); const socketIOSetup = require('./realtime/setupSocketIO'); socketIOSetup.setupNamespace(io); diff --git a/view/loadView.js b/view/loadView.js index c881e3e3a1..e27de8f145 100644 --- a/view/loadView.js +++ b/view/loadView.js @@ -119,6 +119,7 @@ module.exports = function loadView(app, passport) { trackingId: viewConfig.trackingId, user: req.user, eventThrottle: viewConfig.realtimeEventThrottleMilliseconds, + transportProtocol: viewConfig.socketIOtransportProtocol, }; const templateVars = Object.assign( diff --git a/view/perspective/app.js b/view/perspective/app.js index 8446eab23f..57d2b5ce0c 100644 --- a/view/perspective/app.js +++ b/view/perspective/app.js @@ -27,7 +27,8 @@ const ONE = 1; const DEBUG_REALTIME = window.location.href.split(/[&\?]/) .includes('debug=REALTIME'); - +const WEBSOCKET_ONLY = window.location.href.split(/[&\?]/) + .includes('protocol=websocket'); const REQ_HEADERS = { Authorization: u.getCookie('Authorization'), 'X-Requested-With': 'XMLHttpRequest', @@ -95,11 +96,36 @@ function handleEvent(eventData, eventTypeName) { function setupSocketIOClient(persBody) { const namespace = u.getNamespaceString(persBody); + /* + * if the transprotocol is set, initialize the socketio client with + * the transport protocol options. The transProtocol variable is set in + * perspective.pug + */ + const options = {}; + const clientProtocol = transProtocol; + if (clientProtocol) { + /* + * options is used here to set the transport type. For example to only use + * websockets as the transport protocol the options object will be + * { transports: ['websocket'] }. The regex is used to trim the white spaces + * and since clientProtocol is a string of comma seperated values, + * the split function is used to split them out by comma and convert + * it to an array. + */ + options.transports = clientProtocol.replace(/\s*,\s*/g, ',').split(','); + } + /* * Note: The "io" variable is defined by the "/socket.io.js" script included * in perspective.pug. */ - const socket = io(namespace); + let socket; + if (WEBSOCKET_ONLY) { + socket = io(namespace, { transports: ['websocket'] }); + } else { + socket = io(namespace, options); + } + socket.on(eventsQueue.eventType.INTRNL_SUBJ_ADD, (data) => { handleEvent(data, eventsQueue.eventType.INTRNL_SUBJ_ADD); }); diff --git a/view/perspective/perspective.pug b/view/perspective/perspective.pug index 66933a2da7..497cbb8311 100644 --- a/view/perspective/perspective.pug +++ b/view/perspective/perspective.pug @@ -30,6 +30,9 @@ html(lang = 'en') script(src='/static/socket.io.js') script. var realtimeEventThrottleMilliseconds = #{eventThrottle}; + script. + var transProtocol = '#{transportProtocol}'; + script if queryParams | var queryParams = !{queryParams} diff --git a/viewConfig.js b/viewConfig.js index 71300d3ded..bff245a7bd 100644 --- a/viewConfig.js +++ b/viewConfig.js @@ -26,10 +26,14 @@ const DEFAULT_THROTTLE_MILLISECS = 4000; const realtimeEventThrottleMilliseconds = pe.realtimeEventThrottleMilliseconds || DEFAULT_THROTTLE_MILLISECS; +const socketIOtransportProtocol = pe.SOCKETIO_TRANSPORT_PROTOCOL || null; module.exports = { // Make the Google Analytics trackingId available in /view. trackingId: pe.GOOGLE_ANALYTICS_ID || 'N/A', // Make the throttle time available in /view. realtimeEventThrottleMilliseconds, + + // Expose the socketIOtransportProtocol variable in the /view + socketIOtransportProtocol, }; From 897cc424b6761b618a197aefb7ac989a3c7f1776 Mon Sep 17 00:00:00 2001 From: Ian Goldstein Date: Sat, 22 Oct 2016 18:09:24 -0700 Subject: [PATCH 3/5] introduce temporary /perspectiveBeta to improve perspective page load time (#66) * introduce temporary /perspectiveBeta to improve perspective page load time unwinds some of the promises to load all the various pieces asynchronously and render the right pieces at the right time --- view/loadView.js | 6 +- view/perspectiveBeta/CreatePerspective.js | 425 ++++++++++++ view/perspectiveBeta/PerspectiveController.js | 96 +++ view/perspectiveBeta/app.js | 624 ++++++++++++++++++ view/perspectiveBeta/eventsQueue.js | 125 ++++ view/perspectiveBeta/lensUtils.js | 338 ++++++++++ view/perspectiveBeta/perspective.pug | 36 + 7 files changed, 1649 insertions(+), 1 deletion(-) create mode 100644 view/perspectiveBeta/CreatePerspective.js create mode 100644 view/perspectiveBeta/PerspectiveController.js create mode 100644 view/perspectiveBeta/app.js create mode 100644 view/perspectiveBeta/eventsQueue.js create mode 100644 view/perspectiveBeta/lensUtils.js create mode 100644 view/perspectiveBeta/perspective.pug diff --git a/view/loadView.js b/view/loadView.js index e27de8f145..e495272b88 100644 --- a/view/loadView.js +++ b/view/loadView.js @@ -36,6 +36,8 @@ const viewmap = { '/samples/:key/edit': 'admin', '/perspectives': 'perspective/perspective', '/perspectives/:key': 'perspective/perspective', + '/perspectivesBeta': 'perspectiveBeta/perspective', + '/perspectivesBeta/:key': 'perspectiveBeta/perspective', }; /** @@ -131,7 +133,9 @@ module.exports = function loadView(app, passport) { // if url contains a query, render perspective detail page with realtime // updates if ((key === '/perspectives' && Object.keys(req.query).length) || - key === '/perspectives/:key') { + key === '/perspectives/:key' || + (key === '/perspectivesBeta' && Object.keys(req.query).length) || + key === '/perspectivesBeta/:key') { res.render(viewmap[key], templateVars); } else { res.render(viewmap[key], trackObj); diff --git a/view/perspectiveBeta/CreatePerspective.js b/view/perspectiveBeta/CreatePerspective.js new file mode 100644 index 0000000000..9d1ca8167f --- /dev/null +++ b/view/perspectiveBeta/CreatePerspective.js @@ -0,0 +1,425 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * view/perspective/CreatePerspective.js + * + * Shows the perspective's details, and allow user inupt for edit. + */ +import React, { PropTypes } from 'react'; +import Modal from '../admin/components/common/Modal'; +import Pill from '../admin/components/common/Pill'; +import Dropdown from '../admin/components/common/Dropdown'; +import ControlledInput from '../admin/components/common/ControlledInput'; +import ErrorRender from '../admin/components/common/ErrorRender'; +import RadioGroup from '../admin/components/common/RadioGroup'; +const ZERO = 0; +const ONE = 1; +/** + * Given array of objects, returns array of strings or primitives + * of values of the field key + * + * @param {String} field The field of each value to return + * @param {array} arrayOfObjects The array of objects to + * get new array from + * @returns {Array} The array of strings or primitives + */ +function getArray(field, arrayOfObjects) { + let arr = []; + for (let i = arrayOfObjects.length - ONE; i >= ZERO; i--) { + if (arrayOfObjects[i].isPublished) { + arr.push(arrayOfObjects[i][field]); + } + } + return arr; +} + +/** + * Returns the state object without any incidental data. + * @param {Object} stateObject this component's state. + * @ returns {Object} The state with bare minimum data. + */ +function getStateDataOnly(stateObject) { + const stateCopy = JSON.parse(JSON.stringify(stateObject)); + delete stateCopy.dropdownConfig; + delete stateCopy.error; + return stateCopy; +} +/** + * Ie. "thisStringIsGood" --> This String Is Good + * @param {String} string The string to split + * @returns {String} The converted string, includes spaces. + */ +function convertCamelCase(string) { + return string + // insert a space before all caps + .replace(/([A-Z])/g, ' $1') + // uppercase the first character + .replace(/^./, function(string) { return string.toUpperCase(); }); +} + +/** + * @param {DOM_element} el The element to find ancestor with selector from + * @param {String} selector The selector of ancestor + * @returns {DOM_element} The ancestor element that is closest to el + */ +function findCommonAncestor(el, selector) { + let retval = null; + while (el) { + if (el.classList.contains(selector)) { + retval = el; + break; + } + el = el.parentNode; + } + return retval; +} + +class CreatePerspective extends React.Component { + // separate props and status from value prop + constructor(props) { + super(props); + this.appendPill = this.appendPill.bind(this); + this.showError = this.showError.bind(this); + this.deletePill = this.deletePill.bind(this); + this.handleRadioButtonClick = this.handleRadioButtonClick.bind(this); + this.updateDropdownConfig = this.updateDropdownConfig.bind(this); + this.state = { + dropdownConfig: {}, + error: '', + ...props.stateObject, + }; // default values + } + componentDidMount() { + this.updateDropdownConfig(); + } + updateDropdownConfig() { + // attach config to keys, keys to dropdownConfig + const { dropdownConfig, error } = this.state; + let errorMessage = error; + const { values } = this.props; + let stateObject = getStateDataOnly(this.state); + let config = {}; + + for (let key in stateObject) { + const value = this.state[key]; + const convertedText = convertCamelCase(key); + config = { + title: key, + defaultValue: Array.isArray(value) ? value.join('') : value, + placeholderText: 'Select a ' + convertedText, + options: values[key] || [], + showSearchIcon: false, + onClickItem: this.appendPill, + dropDownStyle: { marginTop: 0 }, + showInputWithContent: Array.isArray(value), + }; + if (key === 'subjects') { + config.options = getArray('absolutePath', values[key]); + config.placeholderText = 'Select a Subject...'; + } else if (key === 'lenses') { + config.placeholderText = 'Select a Lens...'; + config.options = getArray('name', values[key]); + } else if (key.slice(-6) === 'Filter') { // if key ends with Filter + config.defaultValue = ''; // should be pills, not text + config.allOptionsLabel = 'All ' + convertedText.replace(' Filter', '') + 's'; + if (key === 'aspectFilter') { + config.options = getArray('name', values[key]); + config.allOptionsLabel = 'All ' + convertedText.replace(' Filter', '') + ' Tags'; + } else if (key === 'statusFilter') { + config.allOptionsLabel = 'All ' + convertedText.replace(' Filter', '') + 'es'; + } + delete config.placeholderText; + // remove value[i] if not in all appropriate values + let notAllowedTags = []; + for (var i = value.length - 1; i >= 0; i--) { + if (!values[key] || values[key].indexOf(value[i]) < ZERO) { + notAllowedTags.push(value[i]); + } + } + if (notAllowedTags.length) { + // remove from state + const newVals = value.filter((item) => { + return notAllowedTags.indexOf(item) < ZERO; + }); + errorMessage += ' ' + convertedText + ' ' + notAllowedTags.join(', ' ) + ' does not exist.'; + const stateRule = { error: errorMessage }; + stateRule[key] = newVals; + this.setState(stateRule); // this won't be called until end of this method. + } + } + dropdownConfig[key] = config; + } + this.setState({ dropdownConfig: dropdownConfig }); + } + handleRadioButtonClick(event) { + const buttonGroup = findCommonAncestor(event.target, 'slds-button-group'); + const filterType = buttonGroup.title; + const stateRule = {}; + stateRule[filterType] = event.target.textContent.toUpperCase(); + this.setState(stateRule); + } + + showError(error) { + let displayError = error; + // if error message is from the API, parse it for content + if (typeof error === 'object') { + displayError = 'status code: ' + + error.status + '. Error: ' + + JSON.parse(error.response.text).errors[ZERO].message; + } + this.setState({ error: displayError }); + } + closeError() { + this.setState({ error: '' }); + } + onInputValueChange(event) { + const value = event.target.value; + // deep copy state object + let stateRule = {}; + stateRule[event.target.name] = value; + this.setState(stateRule); + } + deletePill(event) { + const pillElem = findCommonAncestor(event.target, 'slds-pill'); + const labelContent = pillElem.getElementsByClassName('slds-pill__label')[ZERO].textContent; + const fieldElem = findCommonAncestor(event.target, 'slds-form-element__control'); + const dropdownTitle = fieldElem.title; + const valueInState = this.state[dropdownTitle]; + let newState = this.state; + // if string, delete key, if array, delete from array + if (Array.isArray(valueInState)) { + const index = valueInState.indexOf(labelContent); + valueInState.splice(index, 1); // remove element from array; + newState[dropdownTitle] = valueInState; + } else if (typeof valueInState === 'string') { + newState[dropdownTitle] = ''; + } + + // add selected option to available options in dropdown + newState.dropdownConfig[dropdownTitle].options.push(labelContent); + + // if there's values in dropdown decrement dorpdown margin top. otherwise set margin top to 0 + newState.dropdownConfig[dropdownTitle].dropDownStyle.marginTop = valueInState.length < 1 ? -5 : + this.state.dropdownConfig[dropdownTitle].dropDownStyle.marginTop -= 25; // TODO: get from DOM eleme + this.setState(newState); + } + appendPill(event) { + const valueToAppend = event.target.textContent; + const fieldElem = findCommonAncestor(event.target, 'slds-form-element__control'); + const dropdownTitle = fieldElem.title; + const valueInState = this.state[dropdownTitle]; + let newState = this.state; + // if string, delete key, if array, delete from array + if (Array.isArray(valueInState)) { + newState[dropdownTitle].push(valueToAppend); + } else if (typeof valueInState === 'string') { + newState[dropdownTitle] = valueToAppend; + } + // remove selected option from available options in dropdown + const arr = newState.dropdownConfig[dropdownTitle].options.filter((elem) => { + return elem != valueToAppend; + }); + newState.dropdownConfig[dropdownTitle].options = arr; + + // if there's no pill, use default margin-top + newState.dropdownConfig[dropdownTitle].dropDownStyle.marginTop = valueInState.length < 1 ? -5 : + this.state.dropdownConfig[dropdownTitle].dropDownStyle.marginTop += 25; // TODO: get from DOM eleme + this.setState(newState); + } + doCreate() { + const { values, sendResource } = this.props; + const postObject = getStateDataOnly(this.state); + if (!postObject.lenses.length) { + this.showError('Please enter a valid lens.'); + } else if (!postObject.subjects.length) { + this.showError('Please enter a valid subject.'); + } else if (!postObject.perspectives.length) { + this.showError('Please enter a name for this perspective.'); + } else { + // check if lens field is uid. if not, need to get uid for lens name + const regexpUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!regexpUUID.test(postObject.lenses)) { + let lens = values.lenses.filter((lens) => { + return lens.name === postObject.lenses; + }); + if (!lens.length) { + this.showError('Please enter a valid lens name. No lens with name ' + postObject.lenses + ' found'); + } + + postObject.lenses = lens[ZERO].id; + } + // for create perspectives, rename key lenses --> lensId, + // and perspectives --> name. Start with deep copy values obj + postObject.lensId = postObject.lenses; + postObject.rootSubject = postObject.subjects; + postObject.name = postObject.perspectives; + delete postObject.lenses; + delete postObject.subjects; + delete postObject.perspectives; + // go to created perspective page + sendResource('POST', postObject, this.showError); + } + } + render() { + const { values, cancelCreate } = this.props; + let dropdownObj = {}; + const { dropdownConfig } = this.state; + const radioGroupConfig = {}; + const accountIcon = + + Account + ; + + for (let key in dropdownConfig) { + // if no default value, no pill + let pillOutput = ''; + const value = this.state[key]; + if (key.slice(-4) === 'Type') { + radioGroupConfig[key] = { + highlightFirst: value === 'INCLUDE', + title: key, + onClick: this.handleRadioButtonClick, + } + } + // // if display value is array, use multi pill + // // else single pill + if (value.length) { + if (Array.isArray(value)) { + pillOutput = ; + + } else if (typeof value === 'string') { + pillOutput = ; + } + } + dropdownObj[key] = ( + + { pillOutput } + + ); + } + const errorMessage = this.state.error ? : + ' '; + return ( + +
+
+
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + { dropdownObj.subjects } +
    +
    +
    + +
    + { dropdownObj.lenses } +
    +
    +
    +
    +
    +
    +
    +

    Filters

    +
    +
    +
    +
    + + + { dropdownObj.aspectTagFilter } +
    +
    + + + { dropdownObj.subjectTagFilter } +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + { dropdownObj.aspectFilter } +
    +
    + + + { dropdownObj.statusFilter } +
    +
    +
    +
    +
    +
+
+
+
+ ); + } +} + +CreatePerspective.propTypes = { + cancelCreate: PropTypes.func, + sendResource: PropTypes.func, + values: PropTypes.object, + stateObject: PropTypes.object, +}; + +export default CreatePerspective; diff --git a/view/perspectiveBeta/PerspectiveController.js b/view/perspectiveBeta/PerspectiveController.js new file mode 100644 index 0000000000..862be796e5 --- /dev/null +++ b/view/perspectiveBeta/PerspectiveController.js @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * view/perspective/PerspectiveController.js + * + * Manages perspective page state. + * Passes on data to CreatePerspective + */ +import React, { PropTypes } from 'react'; +import CreatePerspective from './CreatePerspective'; +import Dropdown from '../admin/components/common/Dropdown'; +import request from 'superagent'; +const u = require('../utils'); + +class PerspectiveController extends React.Component { + constructor(props) { + super(props); + this.sendResource = this.sendResource.bind(this); + this.state = { + showCreatePanel: false, + showEditPanel: false, + }; + } + sendResource(verb, formObj, errCallback) { + new Promise((resolve, reject) => { + request(verb, '/v1/perspectives') + .set('Content-Type', 'application/json') + .set('Authorization', u.getCookie('Authorization')) + .send(JSON.stringify(formObj)) + .end((error, response) => { + error ? reject(error) : resolve(response.body); + }); + }).then((res) => { + window.location.href = '/perspectivesBeta/' + res.name; + }) + .catch((err) => { + errCallback(err); + }); + } + goToUrl(event) { + window.location.href = '/perspectivesBeta/' + event.target.textContent; + } + openCreatePanel() { + this.setState({ showCreatePanel: true }); + } + cancelForm() { + this.setState({ showCreatePanel: false }); + } + + render() { + const { values, stateObject } = this.props; + let persNames = []; + if (values && values.perspectives) { + persNames = values.perspectives.map((persObject) => { + return persObject.name; + }); + } + // to hide perspective name on createPerspective modal, + // set perspectives key to value empty + const createPerspectiveVal = JSON.parse(JSON.stringify(stateObject)); + createPerspectiveVal.perspectives = ''; + return ( +
+ + { this.state.showCreatePanel && } +
+ ); + } +} + +PerspectiveController.PropTypes = { + values: PropTypes.object, + stateObject: PropTypes.object, +}; + +export default PerspectiveController; diff --git a/view/perspectiveBeta/app.js b/view/perspectiveBeta/app.js new file mode 100644 index 0000000000..25a8514837 --- /dev/null +++ b/view/perspectiveBeta/app.js @@ -0,0 +1,624 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * view/perspective/app.js + * + * When this page is loaded, we call "getPerspectiveNames" to load all the + * perspective names to populate the dropdown. + * If there are no perspectives, we just render the perspective overlay over an + * empty page. + * If there are perspectives, we call "whichPerspective" to figure out which + * perspective to load. + * If it's not in the URL path, either use the DEFAULT_PERSPECTIVE from global + * config OR the first perspective from the list of perspective names + * (alphabetical order), and redirect to the URL using *that* perspective name. + * Once we can identify the perspective in the URL path, we call + * "getPerspective" to load the specified perspective. When we get the + * perspective back from the server, we perform all of this async work in + * parallel: + * (1) call "setupSocketIOClient" to initialize the socket.io client + * (2) call "getHierarchy" to request the hierarchy + * (3) call "getLensPromise" to request the lens library + * (4) call "loadPerspective" to start rendering the perspective-picker + * component + * (5) call "loadExtraStuffForCreatePerspective" to start loading all the extra + * data we'll need for the "CreatePerspective" component + * + * If config var "eventThrottleMillis" > 0, we start a timer to flush the + * realtime event queue on that defined interval. + * + * Whenever we get the response back with the lens, we dispatch the lens.load + * event to the lens. + * + * Whenever we get the response back with the hierarchy, we dispatch the + * lens.hierarchyLoad event to the lens. (If we happen to get the hierarchy + * back *before* the lens, hold onto it, wait for the lens, *then* dispatch the + * lens.hierarchyLoad event *after* the lens.load event.) + * + * Whenever we get all the extra data we need for "CreatePerspective", we + * re-render the perspective-picker component. + */ +import request from 'superagent'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import PerspectiveController from './PerspectiveController'; +const u = require('../utils'); +const eventsQueue = require('./eventsQueue'); +let gotLens = false; +const lensLoadEvent = new CustomEvent('refocus.lens.load'); +let hierarchyLoadEvent; +const pcValues = {}; + +// TODO get rid of this once all the lenses aren't using it +require('./lensUtils'); + +const ZERO = 0; +const ONE = 1; + +const DEBUG_REALTIME = window.location.href.split(/[&\?]/) + .includes('debug=REALTIME'); + +const REQ_HEADERS = { + Authorization: u.getCookie('Authorization'), + 'X-Requested-With': 'XMLHttpRequest', + Expires: '-1', + 'Cache-Control': 'no-cache,no-store,must-revalidate,max-age=-1,private', +}; +const DEFAULT_ERROR_MESSAGE = 'An unexpected error occurred.'; +const LENS_LIBRARY_REX = /(?:\.([^.]+))?$/; + +// Some API endpoints... +const GET_DEFAULT_PERSPECTIVE = '/v1/globalconfig/DEFAULT_PERSPECTIVE'; +const GET_PERSPECTIVE_NAMES = '/v1/perspectives?fields=name'; + +// Some divs on the perspective page... +const LENS_DIV = document.getElementById('lens'); +const ERROR_INFO_DIV = document.getElementById('errorInfo'); +const PERSPECTIVE_CONTAINER = + document.getElementById('refocus_perspective_dropdown_container'); + +// Note: realtimeEventThrottleMilliseconds is defined in perspective.pug +const eventThrottleMillis = realtimeEventThrottleMilliseconds; + +/** + * Add error message to the errorInfo div in the page. + * + * @param {Object} err - The error object + */ +function handleError(err) { + let msg = DEFAULT_ERROR_MESSAGE; + if (err.response.body.errors[ZERO].description) { + msg = err.response.body.errors[ZERO].description; + } + + ERROR_INFO_DIV.innerHTML = msg; +} // handleError + +/** + * Handle event data, push the event data to the event queue. + * + * @param {String} eventData - Data recieved with event + * @param {String} eventTypeName - Event type + */ +function handleEvent(eventData, eventTypeName) { + const j = JSON.parse(eventData); + if (DEBUG_REALTIME) { + console.log({ // eslint-disable-line no-console + handleEventTimestamp: new Date(), + eventData: j, + }); + } + + eventsQueue.enqueueEvent(eventTypeName, j[eventTypeName]); + if (eventThrottleMillis === ZERO) { + eventsQueue.createAndDispatchLensEvent(eventsQueue.queue, LENS_DIV); + eventsQueue.queue.length = ZERO; + } +} // handleEvent + +/** + * Setup the socket.io client to listen to a namespace, where the namespace is + * named for the root subject of the perspective. + * + * @param {Object} persBody - Perspective object + */ +function setupSocketIOClient(persBody) { + const namespace = u.getNamespaceString(persBody); + + /* + * Note: The "io" variable is defined by the "/socket.io.js" script included + * in perspective.pug. + */ + const socket = io(namespace); + socket.on(eventsQueue.eventType.INTRNL_SUBJ_ADD, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SUBJ_ADD); + }); + socket.on(eventsQueue.eventType.INTRNL_SUBJ_DEL, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SUBJ_DEL); + }); + socket.on(eventsQueue.eventType.INTRNL_SUBJ_UPD, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SUBJ_UPD); + }); + socket.on(eventsQueue.eventType.INTRNL_SMPL_ADD, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SMPL_ADD); + }); + socket.on(eventsQueue.eventType.INTRNL_SMPL_DEL, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SMPL_DEL); + }); + socket.on(eventsQueue.eventType.INTRNL_SMPL_UPD, (data) => { + handleEvent(data, eventsQueue.eventType.INTRNL_SMPL_UPD); + }); +} // setupSocketIOClient + +/** + * Create style tag for lens css file. + * @param {Object} lensResponse Response from lens api + * @param {String} filename name of file in lens library + */ +function injectStyleTag(lensResponse, filename) { + const style = document.createElement('style'); + style.type = 'text/css'; + + const t = document.createTextNode(lensResponse.body.library[filename]); + style.appendChild(t); + const head = document.head || + document.getElementsByTagName('head')[ZERO]; + + if (style.styleSheet) { + style.styleSheet.cssText = lensResponse.body.library[filename]; + } else { + style.appendChild( + document.createTextNode(lensResponse.body.library[filename]) + ); + } + + head.appendChild(style); +} // injectStyleTag + +/** + * Create DOM elements for each of the files in the lens library. + * + * @param {Object} res - Response from lens api call + */ +function handleLibraryFiles(res) { + const lib = res.body.library; + const lensScript = document.createElement('script'); + for (const filename in lib) { + const ext = (LENS_LIBRARY_REX.exec(filename)[ONE] || '').toLowerCase(); + if (filename === 'lens.js') { + lensScript.appendChild(document.createTextNode(lib[filename])); + } else if (ext === 'css') { + injectStyleTag(res, filename); + } else if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') { + const image = new Image(); + image.src = 'data:image/' + ext + ';base64,' + lib[filename]; + document.body.appendChild(image); + } else if (ext === 'js') { + const s = document.createElement('script'); + s.appendChild(document.createTextNode(lib[filename])); + document.body.appendChild(s); + } + } + + /* + * Note: this 'lens.js' script should always get added as the LAST script + * since it may reference things defined in the other scripts. + */ + document.body.appendChild(lensScript); +} // handleLibraryFiles + +/** + * Generate the filter string for the hierarchy API GET. + * + * @param {Object} p - The perspective object + * @returns {String} - The query string created generated based on the + * perspective filters + */ +function getFilterQuery(p) { + let q = '?'; + if (p.aspectFilter && p.aspectFilter.length) { + const sign = p.aspectFilterType === 'INCLUDE' ? '=' : '=-'; + q += 'aspect' + sign + p.aspectFilter.join(); + } + + if (p.aspectTagFilter && p.aspectTagFilter.length) { + if (!q.endsWith('?')) { + q += '&'; + } + + const sign = p.aspectTagFilterType === 'INCLUDE' ? '=' : '=-'; + q += 'aspectTags' + sign + p.aspectTagFilter.join(); + } + + if (p.subjectTagFilter && p.subjectTagFilter.length) { + if (!q.endsWith('?')) { + q += '&'; + } + + const sign = p.subjectTagFilterType === 'INCLUDE' ? '=' : '=-'; + q += 'subjectTags' + sign + p.subjectTagFilter.join(); + } + + if (p.statusFilter) { + if (!q.endsWith('?')) { + q += '&'; + } + + const sign = p.statusFilterType === 'INCLUDE' ? '=' : '=-'; + q += 'status' + sign + p.statusFilter.join(); + } + + return q; +} // getFilterQuery + +/** + * @param {String} name The key to the returned response object + * @param {String} url The url to get from + * param {Function} callback Additional processing with result + * @returns {Promise} For use in chaining. + */ +function getPromiseWithUrl(name, url, callback) { + return new Promise((resolve, reject) => { + request.get(url) + .set(REQ_HEADERS) + .end((error, response) => { + // reject if error is present, otherwise resolve request + if (error) { + document.getElementById('errorInfo').innerHTML += 'Failed to GET ' + + url + '. Make sure the path is valid and the resource is published.'; + reject(error); + } else { + if (callback) { + callback(response); // pass in the complete result + } + const obj = name ? { name: name, res: response.body } : response.body; + resolve(obj); + } + }); + }); +} // getHierarchyData + +/** + * Given the rootSubject, gets subject hierarchy + * and returns a promise to load rootSubject + * + * @param {String} rootSubject The subject to load the hierarchy of + * @param {String} filterString Any filters + * @returns {Promise} which resolves once we receive the hierarchy + */ +function getHierarchy(rootSubject, filterString) { + const apiPath = `/v1/subjects/${rootSubject}/hierarchy` + + (filterString || ''); + return getPromiseWithUrl('rootSubject', apiPath, (res) => { + hierarchyLoadEvent = new CustomEvent('refocus.lens.hierarchyLoad', { + detail: res.body, + }); + + /* + * The order of events matters so only dispatch the hierarchyLoad event if + * we ahve already gotten the lens response back. If hierarchy happens to + * have come back first, then it will be dispatched from getLensPromise. + */ + if (gotLens) { + LENS_DIV.dispatchEvent(hierarchyLoadEvent); + } + }); +} // getHierarchy + +/** + * @param {String} lensNameOrId + * @returns {Promise} which resolves once we receive the lens + */ +function getLensPromise(lensNameOrId) { + const apiPath = `/v1/lenses/${lensNameOrId}`; + return getPromiseWithUrl('lens', apiPath, (res) => { + // inject lens library files in perspective view. + handleLibraryFiles(res); + + // remove spinner and load lens + const spinner = document.getElementById('lens_loading_spinner'); + spinner.parentNode.removeChild(spinner); + + // trigger refocus.lens.load event + gotLens = true; + LENS_DIV.dispatchEvent(lensLoadEvent); + + /* + * The order of events matters so if we happened to have gotten the + * hierarchy *before* the lens, then dispatch the lens.hierarchyLoad event + * now. + */ + if (hierarchyLoadEvent) { + LENS_DIV.dispatchEvent(hierarchyLoadEvent); + } + }); +} // getLensPromise + +/** + * Any last additions to show on create and detail view + * @returns {Object} The object with data from queryParam + */ +function getAllParams() { + const responseObject = {}; + const { rootSubject, lens } = queryParams; // defined in pug file + responseObject.subjects = rootSubject || ''; // single + responseObject.lenses = lens || ''; // single + // multiples, may start with -. If so, use exclude filter + const filterA = [ + 'aspectFilter', 'aspectTagFilter', 'subjectTagFilter', 'statusFilter', + ]; + for (let i = filterA.length - ONE; i >= ZERO; i--) { + const currentVal = queryParams[filterA[i]]; + if (currentVal) { + if (currentVal.slice(ZERO, ONE) === '-') { + responseObject[filterA[i] + 'Type'] = 'EXCLUDE'; + responseObject[filterA[i]] = currentVal.slice(ONE).split(','); + } else { // filter exists, and is an include + responseObject[filterA[i] + 'Type'] = 'INCLUDE'; + responseObject[filterA[i]] = currentVal.split(','); + } + } else { // filter is empty or is not in params + responseObject[filterA[i] + 'Type'] = 'INCLUDE'; + responseObject[filterA[i]] = []; + } + } + + return responseObject; +} // getAllParams + +/** + * Returns array of objects with tags + * @param {Array} array The array of reosurces to get tags from. + * @returns {Object} array of tags + */ +function getTagsFromResources(array) { + // get all tags + const allTags = []; + array.map((obj) => { + if (obj.tags.length) { + allTags.push(...obj.tags); + } + }); + const tagNames = []; + + // get through tags, get all names + allTags.map((tagObj) => { + if (tagNames.indexOf(tagObj.toLowerCase()) === -1) { + tagNames.push(tagObj); + } + }); + return tagNames; +} + +function getPublishedObjectsbyField(array, field) { + return array.filter((obj) => + obj.isPublished).map((obj) => obj[field]) +} + +/** + * @param {Object} perspective An object + */ +function loadPerspective(perspective, params) { + pcValues.name = perspective.name; + const stateObject = Object.assign( + { perspectives: perspective ? perspective.name : '' }, + params + ); + getPromiseWithUrl('perspectives', '/v1/perspectives') + .then((values) => { + pcValues.perspectives = values.res; + loadController(pcValues, stateObject); + }); +} // loadPerspective + +/** + * @param {Array} promisesArr An array of AJAX GET promises. + * @param {boolean} getRoot Get all subjects, set first published subject as + * rootSubject + * @param {boolean} getLens Get all lenses, use the first published lens + */ +function loadExtraStuffForCreatePerspective(perspective, params, promisesArr, + getRoot, getLens) { + pcValues.name = perspective.name; + const stateObject = Object.assign( + { perspectives: perspective ? perspective.name : '' }, + params + ); + const pArr = promisesArr || []; + + const getAllSubjectsPromise = getPromiseWithUrl('subjects', '/v1/subjects'); + let subjectPromise; + if (getRoot) { + subjectPromise = getAllSubjectsPromise.then((val) => { + pcValues.subjects = val.res; + + // get the first published subject, sorted in alphabetical order by + // absolutePath + const rootSubject = getPublishedObjectsbyField(val.res, 'absolutePath') + .sort()[ZERO]; + return getHierarchy(rootSubject); + }); + } else { + subjectPromise = getAllSubjectsPromise; + } + + pArr.push(subjectPromise); + + const getAllLensesPromise = getPromiseWithUrl('lenses', '/v1/lenses'); + let lensPromise; + if (getLens) { + lensPromise = getAllLensesPromise.then((val) => { + pcValues.lenses = val.res; + + // get the first published lens, sorted in alphabetical order by name + const lens = getPublishedObjectsbyField(val.res, 'name').sort()[ZERO]; + return getLensPromise(lens); + }); + } else { + lensPromise = getAllLensesPromise; + } + + pArr.push(lensPromise); + + pArr.push(getPromiseWithUrl('aspectFilter', '/v1/aspects')); + + // TODO change this to GET from API, after its implemented; + const statusFilter = [ + 'Critical', + 'Invalid', + 'Timeout', + 'Warning', + 'Info', + 'OK', + ]; + Promise.all(pArr).then((values) => { + for (let i = values.length - ONE; i >= ZERO; i--) { + pcValues[values[i].name] = values[i].res; + } + + pcValues.statusFilter = statusFilter; + pcValues.aspectTags = getTagsFromResources(pcValues.aspectFilter); + pcValues.subjectTagFilter = getTagsFromResources(pcValues.subjects); + loadController(pcValues, stateObject); + }); +} // loadExtraStuffForCreatePerspective + +function handleUnnamedPerspective() { + const promisesArr = []; + // fill in missing info from params + const { rootSubject, lens } = queryParams; + let getRoot = false; + let getLens = false; + + // if no loadObj.rootSubject, rootSubject is the first subject in GET subject + if (rootSubject) { + promisesArr.push(getHierarchy(rootSubject)); + } else if (!rootSubject) { // no queryParam.rootSubject: need to pass it to loadPerspective + getRoot = true; + } + + if (lens) { + promisesArr.push(getLensPromise(lens)); + } else if (!lens) { + getLens = true; + } + + const params = getAllParams(); + loadPerspective(null, params); + loadExtraStuffForCreatePerspective(null, params, promisesArr, + getRoot, getLens); +} // handleUnnamedPerspective + +/** + * Retrieves the specified perspective, initiates loading lens and hierarchy. + * + * @param {String} perspNameOrId - The name or id of the perspective + */ +function getPerspective(perspNameOrId) { + getPromiseWithUrl('perspective', `/v1/perspectives/${perspNameOrId}`) + .then((val) => { + setupSocketIOClient(val.res); + const { lensId, name, rootSubject } = val.res; + getHierarchy(rootSubject, getFilterQuery(val.res)); + getLensPromise(lensId); + const p = { name, rootSubject, lensId }; + const params = getAllParams(); + loadPerspective(p, params); + loadExtraStuffForCreatePerspective(p, params); + }) + .catch((error) => { + document.getElementById('errorInfo').innerHTML += error; + }); +} + +/** + * Figure out which perspective to load. If it's in the URL path, load that + * one. + * If it's not in the URL path, either use the DEFAULT_PERSPECTIVE from global + * config OR the first perspective from the list of perspective names + * (alphabetical order), and redirect to the URL using that perspective name. + * + * @param {Array} pnames - Array of perspective names + */ +function whichPerspective(pnames) { + let h = window.location.href; + if (!h.endsWith('/')) { + h += '/'; + } + + let hsplit = h.split('/'); + hsplit.pop(); + let p = hsplit.pop(); + if (p && p !== 'perspectivesBeta') { + getPerspective(p); + } else { + request.get(GET_DEFAULT_PERSPECTIVE) + .set(REQ_HEADERS) + .end((err, res) => { + if (err) { + p = pnames.shift(); // Grab the first one from the list + } else { + p = res.body.value; + } + + // Add the perspective name to the URL and redirect. + window.location.href = h + p; + }); + } +} // whichPerspective + +/** + * Load all the perspective names to populate the dropdown. If there are no + * perspectives, just render the perspective overlay over an empty page. + */ +function getPerspectiveNames() { + request.get(GET_PERSPECTIVE_NAMES) + .set(REQ_HEADERS) + .end((err, res) => { + const pnames = []; + if (err) { + handleError(err); + } else { + if (res.body.length === 0) { + loadController({}, {}); + } else { + for (let i = 0; i < res.body.length; i++) { + pnames.push(res.body[i].name); + } + + pnames.sort(); + whichPerspective(pnames); + } + } + }); +} // getPerspectiveNames + +window.onload = () => { + getPerspectiveNames(); +}; + +if (eventThrottleMillis !== ZERO) { + eventsQueue.scheduleFlushQueue(LENS_DIV, eventThrottleMillis); +} + +/** + * Passes data on to Controller to pass onto renderers. + * + * @param {Object} values Data returned from AJAX. + * @param {Object} stateObject Data from queryParams. + */ +function loadController(values, stateObject) { + ReactDOM.render( + , + PERSPECTIVE_CONTAINER + ); +} diff --git a/view/perspectiveBeta/eventsQueue.js b/view/perspectiveBeta/eventsQueue.js new file mode 100644 index 0000000000..3482a83908 --- /dev/null +++ b/view/perspectiveBeta/eventsQueue.js @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + + +'use strict'; +const ZERO = 0; + +let queue = []; + +const eventType = { + INTRNL_SUBJ_ADD: 'refocus.internal.realtime.subject.add', + INTRNL_SUBJ_DEL: 'refocus.internal.realtime.subject.remove', + INTRNL_SUBJ_UPD: 'refocus.internal.realtime.subject.update', + INTRNL_SMPL_ADD: 'refocus.internal.realtime.sample.add', + INTRNL_SMPL_DEL: 'refocus.internal.realtime.sample.remove', + INTRNL_SMPL_UPD: 'refocus.internal.realtime.sample.update', + LENS_CHANGE: 'refocus.lens.realtime.change', +}; + +/** + * Push event data to corresponding event queue. + * @param {String} eventName - Event type name. + * @param {Object} eventData - JSON Data recieved with event. + */ +function enqueueEvent(eventName, eventData) { + if (eventName === eventType.INTRNL_SUBJ_ADD) { + queue.push({ 'subject.add': eventData }); + } else if (eventName === eventType.INTRNL_SUBJ_DEL) { + queue.push({ 'subject.remove': eventData }); + } else if (eventName === eventType.INTRNL_SUBJ_UPD) { + queue.push({ 'subject.update': eventData }); + } else if (eventName === eventType.INTRNL_SMPL_ADD) { + queue.push({ 'sample.add': eventData }); + } else if (eventName === eventType.INTRNL_SMPL_DEL) { + queue.push({ 'sample.remove': eventData }); + } else if (eventName === eventType.INTRNL_SMPL_UPD) { + queue.push({ 'sample.update': eventData }); + } +} + +/** + * Create and dispatch custom event from event queue and empty the same for + * another batch of events. + * @param {Object} queueToFlush - event queue to flush + */ +function createAndDispatchLensEvent(queueToFlush, lensElement) { + if (queueToFlush !== undefined && queueToFlush.length > ZERO) { + const evt = new CustomEvent( + eventType.LENS_CHANGE, { detail: queueToFlush } + ); + lensElement.dispatchEvent(evt); + } +} + +/** + * Clone object to handle race conditions. + * @param {Object} obj - Object to copy + * @returns {Object} copy - New copied object + */ +function clone(obj) { + let copy; + + // Handle simple types, and null or undefined + if (obj === null || typeof obj !== 'object') { + return obj; + } + + // Handle Date + if (obj instanceof Date) { + copy = new Date(); + copy.setTime(obj.getTime()); + return copy; + } + + // Handle Array + if (obj instanceof Array) { + copy = []; + for (let i = 0, len = obj.length; i < len; i++) { + copy[i] = clone(obj[i]); + } + + return copy; + } + + // Handle Object + if (obj instanceof Object) { + copy = {}; + for (const attr in obj) { + if (obj.hasOwnProperty(attr)) { + copy[attr] = clone(obj[attr]); + } + } + + return copy; + } + + throw new Error("Unable to copy obj! Its type isn't supported."); +} + +/** + * schedule flushing of queue after given time interval. + * @param {Object} lensElement - document lens element + */ +function scheduleFlushQueue(lensElement, realtimeEventThrottleMilliseconds) { + // clone events queue, initialize events queue, flush events queue copy. + setInterval(() => { + const queueCopy = clone(queue); + queue.length = 0; + createAndDispatchLensEvent(queueCopy, lensElement); + }, realtimeEventThrottleMilliseconds); +} + +module.exports = { + enqueueEvent, + scheduleFlushQueue, + eventType, + queue, + clone, + createAndDispatchLensEvent, +}; diff --git a/view/perspectiveBeta/lensUtils.js b/view/perspectiveBeta/lensUtils.js new file mode 100644 index 0000000000..29ef16db07 --- /dev/null +++ b/view/perspectiveBeta/lensUtils.js @@ -0,0 +1,338 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * DO NOT MODIFY THIS FILE. WE CANNOT GUARANTEE THAT YOUR LENS WILL WORK ONCE + * IT IS INSTALLED INTO A REFOCUS INSTALLATION. + */ +const lensUtils = { + badStatusArrayWorstToBest: + ['Critical', 'Invalid', 'Timeout', 'Warning', 'Info'], + statusArrayWorstToBest: + ['Critical', 'Invalid', 'Timeout', 'Warning', 'Info', 'OK'], + statuses: { + Critical: 'Critical', + Invalid: 'Invalid', + Timeout: 'Timeout', + Warning: 'Warning', + Info: 'Info', + OK: 'OK', + }, + copyObject(obj) { + return JSON.parse(JSON.stringify(obj)); + }, + /* + * Derive Functions (use with lensUtils.transform) + */ + derive: { + subject: { + /* + * Add a "statusChangedAt" attribute to a subject with samples, + * assigning its value to be the most recent "statusChangedAt" time of + * all its samples. + */ + mostRecentStatusChangedAt(subject) { + if (subject.samples && Object.keys(subject.samples).length) { + subject.statusChangedAt = Object.keys(subject.samples) + .map((s) => subject.samples[s]) + .sort(lensUtils.sort.sample.statusChangedAtDescending) + [0].statusChangedAt; + } + }, + /* + * Add a "statusCounts" attribute to a subject with samples, counting + * the number of samples by status. + */ + statusCounts(subject) { + if (subject.samples && Object.keys(subject.samples).length) { + subject.statusCounts = {}; + lensUtils.statusArrayWorstToBest.forEach((s) => subject.statusCounts[s] = 0); + Object.keys(subject.samples).forEach((s) => { + const sam = subject.samples[s]; + subject.statusCounts[sam.status]++; + }); + } + }, + /* + * Add a "status" attribute to a subject with samples, assigning its + * value to be the worst status of all its samples. + */ + worstStatus(subject) { + if (subject.samples && Object.keys(subject.samples).length) { + subject.status = Object.keys(subject.samples) + .map((s) => subject.samples[s]) + .sort(lensUtils.sort.sample.statusWorstToBestNameAscending) + [0].status; + } + }, + /* + * Add a "status" attribute to a subject with samples, assigning its + * value to be the worst status of all its samples, and add a + * "descendentStatus" attribute to a subject with children, assigning + * its value to be the worst status of all its children. + */ + worstStatusAndDescendentStatus(subject) { + if (subject.samples && Object.keys(subject.samples).length) { + subject.status = Object.keys(subject.samples) + .map((s) => subject.samples[s]) + .sort(lensUtils.sort.sample.statusWorstToBestNameAscending) + [0].status; + } + if (subject.children) { + const descendents = subject.children.sort((a, b) => { + const asorter = lensUtils.statusArrayWorstToBest + .indexOf(a.status || a.descendentStatus) + '#' + a.absolutePath; + const bsorter = lensUtils.statusArrayWorstToBest + .indexOf(b.status || b.descendentStatus) + '#' + b.absolutePath; + return lensUtils.sort.ascending(asorter, bsorter); + }); + subject.descendentStatus = descendents[0].status; + } + }, + }, + }, + /* + * Constants to help shorten your references to the refocus.lens.* events. + */ + evt: { + hLoad: 'refocus.lens.hierarchyLoad', + load: 'refocus.lens.load', + sampAdd: 'refocus.lens.realtime.sample.add', + sampRem: 'refocus.lens.realtime.sample.remove', + sampUpd: 'refocus.lens.realtime.sample.update', + subjAdd: 'refocus.lens.realtime.subject.add', + subjRem: 'refocus.lens.realtime.subject.remove', + subjUpd: 'refocus.lens.realtime.subject.update', + }, + /* + * Filter Functions + */ + filter: { + /* + * Sample Filter Functions + */ + sample: { + /* + * Filters a sample array returning only those sample with status != + * "OK". + */ + notOK(sample, idx, arr) { + return sample.status !== statuses.OK; + }, + /* + * Filters a sample array returning only those sample with status = + * "OK". + */ + onlyOK(sample, idx, arr) { + return sample.status === statuses.OK; + }, + }, + /* + * Subjet Filter Functions + */ + subject: { + /* + * Filters a subject array returning only those subjects which have one + * or more samples, where at least one of the subjects' samples has a + * status != "OK". + */ + notOK(subject, idx, arr) { + const keys = Object.keys(subject.samples); + if (keys.length === 0) { + return false; + } + + keys.forEach((key) => { + const st = subject.samples[key].status; + if (badStatusArrayWorstToBest.indexOf(st) >= 0) { + return true; + } + }); + + return false; + }, + /* + * Filters a subject array returning only those subjects which have one + * or more samples, where *all* of the subjects' samples have status = + * "OK". + */ + allOK(subject, idx, arr) { + const keys = Object.keys(subject.samples); + if (keys.length === 0) { + return false; + } + + keys.forEach((key) => { + if (subject.samples[key].status !== statuses.OK) { + return false; + } + }); + + return true; + }, + }, + }, + /* + * Return a compact description of the time elapsed over the number of + * milliseconds provided. The result always starts with an integer which + * is followed by a single letter representing the unit: "s" for second, "m" + * for minute, "h" for hour, "d" for day and "w" for week. + * + * @param {Integer} ms - The number of milliseconds + * @returns {String} - A compact description of the time elapsed + */ + formatElapsed(ms) { + const ONE_SECOND_IN_MILLIS = 1000; + const ONE_MINUTE_IN_SECONDS = 60; + const ONE_HOUR_IN_MINUTES = 60; + const ONE_DAY_IN_HOURS = 24; + const ONE_WEEK_IN_DAYS = 7; + const seconds = Math.floor(ms / ONE_SECOND_IN_MILLIS); + + if (seconds < ONE_MINUTE_IN_SECONDS) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / ONE_MINUTE_IN_SECONDS); + if (minutes < ONE_HOUR_IN_MINUTES) { + return `${minutes}m`; + } + + const hours = Math.floor(minutes / ONE_HOUR_IN_MINUTES); + if (hours < ONE_DAY_IN_HOURS) { + return `${hours}h`; + } + + const days = Math.floor(hours / ONE_DAY_IN_HOURS); + if (days < ONE_WEEK_IN_DAYS) { + return `${days}d`; + } + + const weeks = Math.floor(days / ONE_WEEK_IN_DAYS); + return `${weeks}w`; + }, + /* + * Comparator Functions + */ + sort: { + ascending(a, b) { + if (a > b) { + return 1; + } + + if (a < b) { + return -1; + } + + return 0; + }, + descending(a, b) { + if (a > b) { + return -1; + } + + if (a < b) { + return 1; + } + + return 0; + }, + /* + * Sample Comparator Functions + */ + sample: { + /* + * Sorts an array of samples in ascending order by name. + */ + nameAscending(a, b) { + return lensUtils.sort.ascending(a.name, b.name); + }, + /* + * Sorts an array of samples in by status (worst to best) then within + * status in ascending order by sample name. + */ + statusWorstToBestNameAscending(a, b) { + const asorter = + lensUtils.statusArrayWorstToBest.indexOf(a.status) + '#' + a.name; + const bsorter = + lensUtils.statusArrayWorstToBest.indexOf(b.status) + '#' + b.name; + return lensUtils.sort.ascending(asorter, bsorter); + }, + /* + * Sorts an array of samples in descending order by statusChangedAt. + */ + statusChangedAtDescending(a, b) { + return lensUtils.sort.descending( + a.statusChangedAt, b.statusChangedAt); + }, + }, + /* + * Subject Comparator Functions + */ + subject: { + /* + * Sorts an array of subjects in ascending order by absolutePath. + */ + absolutePathAscending(a, b) { + return lensUtils.sort.ascending(a.absolutePath, b.absolutePath); + }, + }, + /* + * Tag Comparator Functions + */ + tag: { + nameAscending(a, b) { + return lensUtils.sort.ascending(a.name, b.name); + }, + } + }, + /* + * Returns a copy of the hierarchy with new derived fields based on the + * attribute derivation callback functions provided. + */ + transform(subj) { + function recurse(subject) { + if (subject.children) { + subject.children.forEach((child) => { + recurse(child); + }); + } + deriveFns.forEach((fn) => { + fn(subject); + }); + } + + const h = lensUtils.copyObject(subj); + let deriveFns = []; + for (let i = 1; i < arguments.length; i++) { + deriveFns.push(arguments[i]); + } + + recurse(h); + return h; + }, + /* + * Recursively traverse the hierarchy (depth-first traversal) and execute + * the callback function you provide for every subject in the hierarchy. If + * you do not provide a comparator function argument, subject siblings will + * be sorted using the sort function + * lensUtils.sort.subject.absolutePathAscending. The callback function + * argument takes one argument, a reference to the current subject. + * Best practice: do not modify the subject from your callback function. + */ + traverseSubjects(subj, callback, comparator) { + callback(subj); + if (subj.children) { + subj.children + .sort(comparator || lensUtils.sort.subject.absolutePathAscending) + .forEach((child) => { + lensUtils.traverseSubjects(child, callback, comparator); + }); + } + }, +}; diff --git a/view/perspectiveBeta/perspective.pug b/view/perspectiveBeta/perspective.pug new file mode 100644 index 0000000000..1dc15c2ef1 --- /dev/null +++ b/view/perspectiveBeta/perspective.pug @@ -0,0 +1,36 @@ +// + Copyright (c) 2016, salesforce.com, inc. + All rights reserved. + Licensed under the BSD 3-Clause license. + For full license text, see LICENSE.txt file in the repo root or + https://opensource.org/licenses/BSD-3-Clause + + +doctype html +html(lang = 'en') + head + link(rel='shortcut icon', href='favicon.ico') + link(rel='stylesheet', type='text/css', href='../static/css/salesforce-lightning-design-system.min.css') + link(rel='stylesheet' type='text/css' href='../static/css/perspective.css') + + body + #refocus_perspective_dropdown_container + #create_perspective_container + label#errorInfo + div#lens + div#lens_loading_spinner + .slds-spinner_container + .slds-spinner--brand.slds-spinner.slds-spinner--medium(role='alert') + span.slds-assistive-text Loading + | + .slds-spinner__dot-a + | + .slds-spinner__dot-b + + script(src='/static/socket.io.js') + script. + var realtimeEventThrottleMilliseconds = #{eventThrottle}; + script + if queryParams + | var queryParams = !{queryParams} + script(src='/static/perspectiveBeta/app.js') From 5026e15cd330abb570214241fbe34efa71fece95 Mon Sep 17 00:00:00 2001 From: Ian Goldstein Date: Mon, 24 Oct 2016 10:43:45 -0700 Subject: [PATCH 4/5] minor readme tweaks and cleanup (#67) --- README.md | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bd5ca46495..609a900f22 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -[![Build Status](https://travis-ci.org/salesforce/refocus.svg?branch=master)](https://travis-ci.org/salesforce/refocus) [![StackShare](http://img.shields.io/badge/tech-stack-0690fa.svg?style=flat)](http://stackshare.io/iamigo/refocus) [![Coverage Status](https://coveralls.io/repos/github/salesforce/refocus/badge.svg?branch=master)](https://coveralls.io/github/salesforce/refocus?branch=master) [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/Salesforce/refocus) +[![Build Status](https://travis-ci.org/salesforce/refocus.svg?branch=master)](https://travis-ci.org/salesforce/refocus) +[![Coverage Status](https://coveralls.io/repos/github/salesforce/refocus/badge.svg?branch=master)](https://coveralls.io/github/salesforce/refocus?branch=master) +[![StackShare](http://img.shields.io/badge/tech-stack-0690fa.svg?style=flat)](http://stackshare.io/iamigo/refocus) + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/Salesforce/refocus) + # Refocus > ## Get started now with our [QuickStart](docs/QuickStart.md) guide! -Refocus is a platform for visualizing the health and status of systems and/or services under observation. It is *not* a monitoring or alerting tool. - -*TODO: We need to flesh out this description to include why you would want to use it, why you would want to integrate it with your existing monitoring tools, etc.* +Refocus is a platform for visualizing the health and status of systems and/or services under observation. Check out our [home page](https://salesforce.github.io/refocus) and our [docs](https://salesforce.github.io/refocus/docs/00-welcome). @@ -94,18 +97,6 @@ After installing the server, you can run ```redis-cli``` to issue commands to re - If you are running the app in more than one dyno, you will need to force the client to communicate with the server only using websockets. To do so, set the config variable SOCKETIO_TRANSPORT_PROTOCOL to websocket or run ```heroku config:set SOCKETIO_TRANSPORT_PROTOCOL=websocket``` If you are running on Heroku and you want to use Google Analytics, store your tracking id in a Heroku config variable called `GOOGLE_ANALYTICS_ID`. -## Using Trace (by RisingStack) for application performance monitoring -- Here is the quick start guide by Trace to start monitoring the app. https://trace-docs.risingstack.com/docs/getting-started -- To monitor the app deployed on heroku, follow the steps here. https://devcenter.heroku.com/articles/trace - -## Configuring New Relic - -### Local Deployment -Add your New Relic license key to an attribute called ```newRelicKey``` in ```config.js``` - -### Heroku Deployment -Install the New Relic add-on--it will automatically set the license key in your heroku environment. - ### Troubleshooting a Heroku deployment - Log errors in suspected areas. Use the logging in the error handler - For errors 'Relation __ does not exist', the db is not set up properly. Try resetting the database. Run ```heroku run bash``` to enter shell script mode, then run ```gulp resetdb```, to reset the db @@ -113,7 +104,7 @@ Install the New Relic add-on--it will automatically set the license key in your - Run ```heroku logs --tail``` to see the heroku logs, as they update ## Setup Production Environment on Localhost -If not already setup, follow Installation instructions to setup Refocus. Execute following commands to setup production environment and corresponding config variables: +If not already setup, follow Installation instructions to setup Refocus. Execute the following commands to setup production environment and corresponding config variables: - Run ```export NODE_ENV=production``` - Run ```export DATABASE_URL='postgres://postgres:postgres@localhost:5432/focusdb'``` - Run ```npm start``` or ```node .``` @@ -158,4 +149,4 @@ The API is self-documenting based on [`./api/v1/swagger.yaml`](./api/v1/swagger. - Node.js [token-based authentication](https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens) ## Contributing -Guidelines on contributing to Refocus are available [here](docs/Contributing.md). +Guidelines on contributing to Refocus are available [here](https://salesforce.github.io/refocus/docs/95-contributing.html). From eb5ae26c51ca2e5bbe6fb774e07626b46a7ff1d2 Mon Sep 17 00:00:00 2001 From: Ian Goldstein Date: Mon, 24 Oct 2016 11:11:27 -0700 Subject: [PATCH 5/5] apply websocket changes to perspectiveBeta (#69) --- view/perspectiveBeta/app.js | 29 +++++++++++++++++++++++++++- view/perspectiveBeta/perspective.pug | 2 ++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/view/perspectiveBeta/app.js b/view/perspectiveBeta/app.js index 25a8514837..d99ff65ba3 100644 --- a/view/perspectiveBeta/app.js +++ b/view/perspectiveBeta/app.js @@ -63,6 +63,8 @@ const ONE = 1; const DEBUG_REALTIME = window.location.href.split(/[&\?]/) .includes('debug=REALTIME'); +const WEBSOCKET_ONLY = window.location.href.split(/[&\?]/) + .includes('protocol=websocket'); const REQ_HEADERS = { Authorization: u.getCookie('Authorization'), @@ -131,11 +133,36 @@ function handleEvent(eventData, eventTypeName) { function setupSocketIOClient(persBody) { const namespace = u.getNamespaceString(persBody); + /* + * if the transprotocol is set, initialize the socketio client with + * the transport protocol options. The transProtocol variable is set in + * perspective.pug + */ + const options = {}; + const clientProtocol = transProtocol; + if (clientProtocol) { + /* + * options is used here to set the transport type. For example to only use + * websockets as the transport protocol the options object will be + * { transports: ['websocket'] }. The regex is used to trim the white spaces + * and since clientProtocol is a string of comma seperated values, + * the split function is used to split them out by comma and convert + * it to an array. + */ + options.transports = clientProtocol.replace(/\s*,\s*/g, ',').split(','); + } + /* * Note: The "io" variable is defined by the "/socket.io.js" script included * in perspective.pug. */ - const socket = io(namespace); + let socket; + if (WEBSOCKET_ONLY) { + socket = io(namespace, { transports: ['websocket'] }); + } else { + socket = io(namespace, options); + } + socket.on(eventsQueue.eventType.INTRNL_SUBJ_ADD, (data) => { handleEvent(data, eventsQueue.eventType.INTRNL_SUBJ_ADD); }); diff --git a/view/perspectiveBeta/perspective.pug b/view/perspectiveBeta/perspective.pug index 1dc15c2ef1..e19a8cc445 100644 --- a/view/perspectiveBeta/perspective.pug +++ b/view/perspectiveBeta/perspective.pug @@ -30,6 +30,8 @@ html(lang = 'en') script(src='/static/socket.io.js') script. var realtimeEventThrottleMilliseconds = #{eventThrottle}; + script. + var transProtocol = '#{transportProtocol}'; script if queryParams | var queryParams = !{queryParams}