From a3181dd5347c43ab661c5e973ba7407ebe33cc88 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Sat, 22 Dec 2018 16:13:13 -0600 Subject: [PATCH 01/14] extend-graph-navigation - initial extension of graph/graphcontainer comps to use redux store/action flow --- app/Dockerfile | 5 +- app/src/actions/actionTypes.js | 1 + app/src/actions/entityActions.js | 55 ++++++++ app/src/actions/queryActions.js | 4 +- app/src/components/graph/Graph.js | 28 +++- app/src/components/graph/GraphContainer.js | 62 ++++++--- app/src/components/home/Home.js | 4 +- app/src/reducers/entityReducer.js | 30 +++++ app/src/reducers/initialState.js | 6 +- .../services/{apiService.js => ApiService.js} | 14 +- .../{httpService.js => HttpService.js} | 0 app/src/utils/graph.js | 127 +++++++++--------- 12 files changed, 233 insertions(+), 103 deletions(-) rename app/src/services/{apiService.js => ApiService.js} (56%) rename app/src/services/{httpService.js => HttpService.js} (100%) diff --git a/app/Dockerfile b/app/Dockerfile index 265c433..f6d153c 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,10 +1,11 @@ FROM node:8.12.0 as build-stage WORKDIR /app -#initially copy over just package.json (and locks, if present) for dependency installation step +#initially copy over just package.json (and locks, if present) for dependency installation COPY package*.json /app/ RUN yarn install -#then copy over everything, including our code, for the build step +#then copy over our code for test+build COPY ./ /app/ +#RUN yarn test RUN yarn build #once the build-stage is complete, we can build an image for our deployment diff --git a/app/src/actions/actionTypes.js b/app/src/actions/actionTypes.js index d6593c5..51dade3 100644 --- a/app/src/actions/actionTypes.js +++ b/app/src/actions/actionTypes.js @@ -2,5 +2,6 @@ export const CHANGE_QUERY = 'CHANGE_QUERY'; export const SUBMIT_QUERY = 'SUBMIT_QUERY'; export const FETCH_QUERY = 'FETCH_QUERY'; export const RECEIVE_QUERY = 'RECEIVE_QUERY'; +export const ADD_ENTITY_WATCH = 'ADD_ENTITY_WATCH'; export const FETCH_ENTITY = 'FETCH_ENTITY'; export const RECEIVE_ENTITY = 'RECEIVE_ENTITY'; \ No newline at end of file diff --git a/app/src/actions/entityActions.js b/app/src/actions/entityActions.js index e69de29..28bb7ec 100644 --- a/app/src/actions/entityActions.js +++ b/app/src/actions/entityActions.js @@ -0,0 +1,55 @@ +import * as types from './actionTypes'; +import ApiService from "../services/ApiService"; + +export const addEntityWatch = uid => ({ + type: types.ADD_ENTITY_WATCH, + uid +}); + +export const fetchEntity = uid => { + return dispatch => { + return ApiService.getEntity(uid) + .then(handleResponse) + .then(json => dispatch(receiveEntity(json[0])));//index into JSON arr to grab single entity object + }; +}; + +export const receiveEntity = json => ({ + type: types.RECEIVE_ENTITY, + results: json +}); + +function handleResponse(json) { + let results = []; + let existingUids = {}; + for (let objKey in json){ + let objArr = json[objKey]; + if (objArr.length) { + objArr.forEach(obj => { + //screen out duplicate UID entries + if(!existingUids[obj.uid]){ + results.push(obj); + existingUids[obj.uid] = true; + } + }); + } + } + return results; +} + +//only update state if the objects fail lodash equality check AND +// //the component is still mounted. usually, the lifecycle methods should +// //be used directly for such things that, but in testing we're getting +// //intermittent errors that setState is being called on unmounted +// //components, without this check +// if(!_.isEqual(this.state.data, json) && this._isMounted) { +// this.setState({ +// data: json, +// waitingOnReq: false +// }); + +//logic here to transform API raw data to something usable by graph, or does that make more sense in actions? + +//also trigger periodic re-requests of data here with knowledge of period, max reqs/sec, num objs tracked? +//no, I think it still makes sense to do that in a component, probably GraphContainer, where we easily have access to +//store to see num of requests needed and can compute interval ms based on limit and num entities diff --git a/app/src/actions/queryActions.js b/app/src/actions/queryActions.js index a64c6ba..faf5b31 100644 --- a/app/src/actions/queryActions.js +++ b/app/src/actions/queryActions.js @@ -1,5 +1,5 @@ import * as types from './actionTypes'; -import {apiService} from "../services/apiService"; +import ApiService from "../services/ApiService"; export const changeQuery = str => ({ type: types.CHANGE_QUERY, @@ -10,7 +10,7 @@ export const submitQuery = () => ({type: types.SUBMIT_QUERY}); export function fetchQuery(query) { return dispatch => { - return apiService.getKeyword(query) + return ApiService.getKeyword(query) .then(handleResponse) .then(json => dispatch(receiveQuery(json))); }; diff --git a/app/src/components/graph/Graph.js b/app/src/components/graph/Graph.js index 5e3609c..6c0fec4 100644 --- a/app/src/components/graph/Graph.js +++ b/app/src/components/graph/Graph.js @@ -1,14 +1,18 @@ import React, { Component } from 'react'; +import { connect } from "react-redux"; +import { bindActionCreators } from "redux"; import { withRouter } from 'react-router-dom'; import _ from 'lodash'; import vis from 'vis'; import { Grid } from '@material-ui/core'; -import {options} from "../../config/visjsConfig"; -import {getVisData, clearVisData, colorMixer, getLegends} from "../../utils/graph"; -import {NodeStatusPulseColors} from "../../config/appConfig"; +import * as entityActions from "../../actions/entityActions"; +import { options } from "../../config/visjsConfig"; +import { getVisData, clearVisData, colorMixer, getLegends } from "../../utils/graph"; +import { NodeStatusPulseColors } from "../../config/appConfig"; import './Graph.css'; + class Graph extends Component { constructor(props) { super(props); @@ -65,6 +69,7 @@ class Graph extends Component { } render() { + //TODO: this is a bit awkward to have to explicitly call a render "helper" function, ideally anything critical would either be done directly in render() or earlier in lifecycle methods, etc. this.renderGraphExpanded(this.state.data); return (
@@ -210,10 +215,10 @@ class Graph extends Component { //configure custom behaviors for network object configNetwork(network) { - network.on("doubleClick", params => { + network.on("doubleClick", element => { //ensure the double click was on a graph node - if (params.nodes.length > 0) { - const targetNodeUid = params.nodes[0]; + if (element.nodes.length > 0) { + const targetNodeUid = element.nodes[0]; const pathComponents = this.props.location.pathname.split('/'); const currentNodeUid = pathComponents[pathComponents.length - 1]; //only update props if target node is not current node @@ -226,4 +231,13 @@ class Graph extends Component { } } -export default withRouter(Graph); \ No newline at end of file +const mapStoreToProps = store => ({entity: store.entity}); + +const mapDispatchToProps = dispatch => ({ + entityActions: bindActionCreators(entityActions, dispatch) +}); + +export default connect( + mapStoreToProps, + mapDispatchToProps +)(withRouter(Graph)); \ No newline at end of file diff --git a/app/src/components/graph/GraphContainer.js b/app/src/components/graph/GraphContainer.js index 06f2261..565cee8 100644 --- a/app/src/components/graph/GraphContainer.js +++ b/app/src/components/graph/GraphContainer.js @@ -1,4 +1,6 @@ import React, { Component } from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import _ from 'lodash'; @@ -7,9 +9,11 @@ import Grid from '@material-ui/core/Grid'; import CircularProgress from '@material-ui/core/CircularProgress'; import EntityDetails from '../entityDetails/EntityDetails'; -import {apiService} from "../../services/apiService"; +import ApiService from "../../services/ApiService"; import Graph from './Graph'; import "./Graph.css"; +import * as entityActions from "../../actions/entityActions"; + const DATA_FETCH_PERIOD_MS = 5000; @@ -66,20 +70,25 @@ class GraphContainer extends Component { const pathComponents = this.props.location.pathname.split('/'); const uidParam = pathComponents[pathComponents.length - 1]; - apiService.getEntity(uidParam) - .then(json => { - //only update state if the objects fail lodash equality check AND - //the component is still mounted. usually, the lifecycle methods should - //be used directly for such things that, but in testing we're getting - //intermittent errors that setState is being called on unmounted - //components, without this check - if(!_.isEqual(this.state.data, json) && this._isMounted) { - this.setState({ - data: json, - waitingOnReq: false - }); - } - }); + //TODO:DM - adding to watch will be redundant, but the UIDs set will enforce uniqueness, should I instead check here? + this.props.entityActions.addEntityWatch(uidParam); + this.props.entityActions.fetchEntity(uidParam); + + //TODO:DM - will I need to do something similar to the use of _isMounted still, for testing? + // ApiService.getEntity(uidParam) + // .then(json => { + // //only update state if the objects fail lodash equality check AND + // //the component is still mounted. usually, the lifecycle methods should + // //be used directly for such things that, but in testing we're getting + // //intermittent errors that setState is being called on unmounted + // //components, without this check + // if(!_.isEqual(this.state.data, json) && this._isMounted) { + // this.setState({ + // data: json, + // waitingOnReq: false + // }); + // } + // }); }; render() { @@ -95,14 +104,14 @@ class GraphContainer extends Component { } - +
- ); + );//get rid of [0] idx ^ } } @@ -110,4 +119,21 @@ GraphContainer.propTypes = { classes: PropTypes.object.isRequired, }; -export default withRouter(withStyles(styles)(GraphContainer)); \ No newline at end of file +const mapStoreToProps = store => ({entity: store.entity}); + +const mapDispatchToProps = dispatch => ({ + entityActions: bindActionCreators(entityActions, dispatch) +}); + +export default connect( + mapStoreToProps, + mapDispatchToProps +)( + withRouter( + withStyles(styles)(GraphContainer) + ) +); + +//also trigger periodic re-requests of data here with knowledge of period, max reqs/sec, num objs tracked? +//no, I think it still makes sense to do that in a component, probably GraphContainer, where we easily have access to +//store to see num of requests needed and can compute interval ms based on limit and num entities \ No newline at end of file diff --git a/app/src/components/home/Home.js b/app/src/components/home/Home.js index 3daf3c9..a7ba8b2 100644 --- a/app/src/components/home/Home.js +++ b/app/src/components/home/Home.js @@ -83,14 +83,14 @@ Home.propTypes = { query: PropTypes.object }; -const mapStateToProps = state => ({query: state.query}); +const mapStoreToProps = store => ({query: store.query}); const mapDispatchToProps = dispatch => ({ queryActions: bindActionCreators(queryActions, dispatch) }); export default connect( - mapStateToProps, + mapStoreToProps, mapDispatchToProps )( withStyles(styles)( diff --git a/app/src/reducers/entityReducer.js b/app/src/reducers/entityReducer.js index e69de29..2caa21d 100644 --- a/app/src/reducers/entityReducer.js +++ b/app/src/reducers/entityReducer.js @@ -0,0 +1,30 @@ +import initialState from './initialState'; +import {ADD_ENTITY_WATCH, FETCH_ENTITY, RECEIVE_ENTITY} from '../actions/actionTypes'; + +export default function entity(state = initialState.entity, action) { + let newState, now; + switch (action.type) { + case ADD_ENTITY_WATCH: + //start with a copy of existing state + newState = { ...state }; + //extend the uidsObj which is otherwise difficult to with variable key + //name action.uid in statement above + newState.uidsObj[action.uid] = true; + return newState; + case FETCH_ENTITY: + return action; + case RECEIVE_ENTITY: + now = +new Date(); + //project changes on top of existing state attrs + newState = { ...state, + //results: action.results, // how to add to existing results? + latestTimestamp: now, + }; + //TODO:DM - technically I could use just 1 obj to maintain UIDs to watch as well as results, just make null until new obj arrives {val: null, timestamp: ...} + newState.results[action.results.uid] = action.results; + return newState; + default: + return state; + } +} + diff --git a/app/src/reducers/initialState.js b/app/src/reducers/initialState.js index 1540c91..aeaa06c 100644 --- a/app/src/reducers/initialState.js +++ b/app/src/reducers/initialState.js @@ -6,5 +6,9 @@ export default { isWaiting: false, results: [] }, - entity: {} + entity: { + uidsObj: {}, + results: {}, + latestTimestamp: 0 + } }; \ No newline at end of file diff --git a/app/src/services/apiService.js b/app/src/services/ApiService.js similarity index 56% rename from app/src/services/apiService.js rename to app/src/services/ApiService.js index 1cb1a87..5c5db04 100644 --- a/app/src/services/apiService.js +++ b/app/src/services/ApiService.js @@ -1,27 +1,31 @@ -import {HttpService} from './httpService'; +import {HttpService} from './HttpService'; const ALL_SERVICE_CONTEXT = '/v1'; const QUERY_SERVICE_PATH = '/query'; const ENTITY_SERVICE_PATH = '/entity/uid/'; -export class apiService { +export default class ApiService { static getKeyword(input) { const params = { keyword: input }; + //load env provided URL at query time to allow conf.js to load it in time + //in testing const ALL_SERVICE_URL = window.envConfig.KATLAS_API_URL; - return request_helper(ALL_SERVICE_URL + ALL_SERVICE_CONTEXT + + return requestHelper(ALL_SERVICE_URL + ALL_SERVICE_CONTEXT + QUERY_SERVICE_PATH, params); } static getEntity(uid) { + //load env provided URL at query time to allow conf.js to load it in time + //in testing const ALL_SERVICE_URL = window.envConfig.KATLAS_API_URL; - return request_helper(ALL_SERVICE_URL + ALL_SERVICE_CONTEXT + + return requestHelper(ALL_SERVICE_URL + ALL_SERVICE_CONTEXT + ENTITY_SERVICE_PATH + uid); } } -const request_helper = (url, params) => { +const requestHelper = (url, params) => { return HttpService.get({ url, params diff --git a/app/src/services/httpService.js b/app/src/services/HttpService.js similarity index 100% rename from app/src/services/httpService.js rename to app/src/services/HttpService.js diff --git a/app/src/utils/graph.js b/app/src/utils/graph.js index 4673b71..c175b9f 100644 --- a/app/src/utils/graph.js +++ b/app/src/utils/graph.js @@ -37,28 +37,25 @@ let edges, nodes; let legendTypesObj = {}; let legendStatusesObj = {}; -function parseDgraphJson(jsonData) { - if (jsonData === null || jsonData === undefined ) { - console.error("Empty json data"); +function parseDgraphData(data) { + if (data === null || data === undefined ) { + console.error("Empty data"); return; } - for (let outerObj of jsonData){ - //TODO:DM - clean up anon fn dependent on closure for outerObj - _.forOwn(outerObj, (innerObj, key) => { - let uid = ""; - if (key === uidPropNameDgraph) { - uid = innerObj; - //Store entire block in map, so we can use to create edges later - //Edges cannot be created here as we may not have got the uid still - //As json from dgraph has randomized order and uid may be after Array elements - uidMap.set(uid, outerObj); - } - if (EdgeLabels.indexOf(key) > -1) { - parseDgraphJson(innerObj); - - } - }); - } + _.forOwn(data, (val, key) => { + let uid = ""; + if (key === uidPropNameDgraph) { + uid = val; + //Store entire block in map, so we can use to create edges later + //Edges cannot be created here as we may not have got the uid still + //As json from dgraph has randomized order and uid may be after Array elements + uidMap.set(uid, data); + } + //if this key is a relationship type (as defined in EdgeLabels) recurse to get children nodes + if (EdgeLabels.indexOf(key) > -1) { + parseDgraphData(val); + } + }); } @@ -160,57 +157,55 @@ function validateJSONData(uid, nodeName, nodeObjtype) { } } -export function getVisData(jsonData) { +export function getVisData(data) { clearVisData(); - if (jsonData && jsonData.objects) { - parseDgraphJson(jsonData.objects); + _.forOwn(data, (entity, key) => { + parseDgraphData(entity); printUidMap(); for (const [uid, v] of uidMap.entries()) { - - let nodeName = "", nodeObjtype = "", nodeStatus = ""; - - const block = v; - - for (const prop in block) { - if (!block.hasOwnProperty(prop)) { - continue; - } - const val = block[prop]; - //determine whether we are looking at a property for this node OR a set of child nodes - if ( - Array.isArray(val) && - val.length > 0 && - typeof val[0] === "object" - ) { - // These are child nodes - for (let i = 0; i < val.length; i++) { - const fromUid = uid; //key for this map entry - const toUid = val[i].uid; - - const e = getVisFormatEdge(fromUid, toUid, prop); - edges.push(e); - } - } else { - //get properties which we need to feed to Visjs Node - if (prop === namePropNameDgraphApp) { - nodeName = val; - } - if (prop === objtypePropNameDgraphApp) { - nodeObjtype = val; - } - if (prop === nodeStatusProp || - prop === nodePhaseProp) { - nodeStatus = val; - } - } - } - validateJSONData(uid, nodeName, nodeObjtype); - let n = getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus); - nodes.push(n); + let nodeName = "", nodeObjtype = "", nodeStatus = ""; + const block = v; + + for (const prop in block) { + if (!block.hasOwnProperty(prop)) { + continue; + } + const val = block[prop]; + //determine whether we are looking at a property for this node OR a set of child nodes + if ( + Array.isArray(val) && + val.length > 0 && + typeof val[0] === "object" + ) { + // These are child nodes + for (let i = 0; i < val.length; i++) { + const fromUid = uid; //key for this map entry + const toUid = val[i].uid; + + const e = getVisFormatEdge(fromUid, toUid, prop); + edges.push(e); + } + } else { + //get properties which we need to feed to Visjs Node + if (prop === namePropNameDgraphApp) { + nodeName = val; + } + if (prop === objtypePropNameDgraphApp) { + nodeObjtype = val; + } + if (prop === nodeStatusProp || + prop === nodePhaseProp) { + nodeStatus = val; + } + } + } + validateJSONData(uid, nodeName, nodeObjtype); + let n = getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus); + nodes.push(n); } - return {nodes, edges}; - } + }); + return {nodes, edges}; } From c7ff270b8c21a77dcc23192b9787c7c01f01f763 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Wed, 26 Dec 2018 14:59:24 -0500 Subject: [PATCH 02/14] extend-graph-navigation - fix merge conflict, need to get upstream master in yet too --- app/src/history.js | 10 ++ app/src/index.js | 13 ++- app/src/utils/graph.js | 213 ++++++++++++++++++++--------------------- 3 files changed, 118 insertions(+), 118 deletions(-) create mode 100644 app/src/history.js diff --git a/app/src/history.js b/app/src/history.js new file mode 100644 index 0000000..06f5ba0 --- /dev/null +++ b/app/src/history.js @@ -0,0 +1,10 @@ +import { createBrowserHistory } from 'history'; + +export default createBrowserHistory({ + //this value is not needed or used during local dev or testing scenarios + //instead, it's required in deployed scenarios where the application is + //deployed on a subdirectory of a host (ex: 'example.com/ui'). the value is + //provided by "homepage" attribute in package.json at webpack build time + //further info here: https://www.npmjs.com/package/history#using-a-base-url + basename: process.env.PUBLIC_URL +}); \ No newline at end of file diff --git a/app/src/index.js b/app/src/index.js index 997a290..a8659e9 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -1,13 +1,14 @@ import React from 'react'; import { Provider } from 'react-redux'; import { render } from 'react-dom'; -import { BrowserRouter } from 'react-router-dom'; +import { Router } from 'react-router-dom'; import { MuiThemeProvider, createMuiTheme } from "@material-ui/core"; import WebFont from 'webfontloader'; -import './index.css'; -import configureStore from './store/configureStore'; import App from './components/app/App'; +import store from './store'; +import history from './history'; +import './index.css'; WebFont.load({ custom: { @@ -31,14 +32,12 @@ const theme = createMuiTheme({ }, }); -const store = configureStore(); - render( - + - + , document.getElementById('root') diff --git a/app/src/utils/graph.js b/app/src/utils/graph.js index c175b9f..bcbdf6f 100644 --- a/app/src/utils/graph.js +++ b/app/src/utils/graph.js @@ -58,103 +58,96 @@ function parseDgraphData(data) { }); } - - function printUidMap() { - console.debug("Printing uidMap"); - for (const [k, v] of uidMap.entries()) { - console.debug(k, v); - } + console.debug("Printing uidMap"); + for (const [k, v] of uidMap.entries()) { + console.debug(k, v); + } } function getNodeIcon(nodeObjtype) { - - if (nodeObjtype === undefined || nodeObjtype === null) { - return NodeIconMap.get("default"); - } - if (NodeIconMap.has(nodeObjtype)) { - return NodeIconMap.get(nodeObjtype); - } else { - return NodeIconMap.get("default"); - } + if (nodeObjtype === undefined || nodeObjtype === null) { + return NodeIconMap.get("default"); + } + if (NodeIconMap.has(nodeObjtype)) { + return NodeIconMap.get(nodeObjtype); + } else { + return NodeIconMap.get("default"); + } } function getEdgeLabelShortHand(prop) { - - let edgeLabel = ""; - if (prop !== "") { - //edgeLabel = prop.substring(0, 1); - edgeLabel = prop; - //edgeLabelMap.set(edgeLabel, prop); - } - return edgeLabel; + let edgeLabel = ""; + if (prop !== "") { + edgeLabel = prop; + } + return edgeLabel; } function getVisFormatEdge(fromUid, toUid, relation) { - - const e = { - from: fromUid, - to: toUid, - label: getEdgeLabelShortHand(relation), - color: { - color: EdgeColorMap.get(NODE_DEFAULT_STR), - inherit: false - }, - font: { - size: 8 - }, - arrows: "to" - }; - return e; + const e = { + from: fromUid, + to: toUid, + label: getEdgeLabelShortHand(relation), + color: { + color: EdgeColorMap.get(NODE_DEFAULT_STR), + inherit: false + }, + font: { + size: 8 + }, + arrows: "to" + }; + return e; } function getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus) { - let idParam = uid; - let titleParam = ""; + let idParam = uid; + let titleParam = ""; - if (nodeObjtype !== undefined && nodeObjtype !== null) { - titleParam = nodeObjtype; - } else { - titleParam = nodeName; - } + if (nodeObjtype !== undefined && nodeObjtype !== null) { + titleParam = nodeObjtype; + } else { + titleParam = nodeName; + } - //some names are too large to render correctly in hierarchy, split them by middle dash across 2 lines of text - nodeName = nameSplitter(nodeName); + //some names are too large to render correctly in hierarchy, split them by middle dash across 2 lines of text + nodeName = nameSplitter(nodeName); - const color = NodeStatusColorMap.get(nodeStatus || NODE_DEFAULT_STR); + const color = NodeStatusColorMap.get(nodeStatus || NODE_DEFAULT_STR); - const n = { - id: idParam, - uid: uid, - label: nodeName, - icon:{ - face: NODE_ICON_FONT, - code: getNodeIcon(nodeObjtype), - size: NODE_ICON_FONT_SIZE, - color: color, - }, - name: nodeName, - title: titleParam, - status: nodeStatus, - }; - legendTypesObj[nodeObjtype] = { + const n = { + id: idParam, + uid: uid, + label: nodeName, + icon:{ + face: NODE_ICON_FONT, code: getNodeIcon(nodeObjtype), - color: NODE_DEFAULT_COLOR, - }; - legendStatusesObj[nodeStatus] = { - code: getNodeIcon(NODE_DEFAULT_STR), + size: NODE_ICON_FONT_SIZE, color: color, - }; - return n; + }, + name: nodeName, + title: titleParam, + status: nodeStatus, + }; + legendTypesObj[nodeObjtype] = { + code: getNodeIcon(nodeObjtype), + color: NODE_DEFAULT_COLOR, + }; + legendStatusesObj[nodeStatus] = { + code: getNodeIcon(NODE_DEFAULT_STR), + color: color, + }; + return n; } function validateJSONData(uid, nodeName, nodeObjtype) { - if (nodeName === "") { - console.error(`JSON Error - Attribute ${namePropNameDgraphApp} missing for uid = ${uid}`); - } - if (nodeObjtype === "") { - console.error(`JSON Error - Attribute ${objtypePropNameDgraphApp} missing for uid = ${uid}`); - } + if (nodeName === "") { + console.error(`JSON Error - Attribute ${namePropNameDgraphApp} missing for uid = ${uid}`); + } + if (nodeObjtype === "") { + console.error(`JSON Error - Attribute ${objtypePropNameDgraphApp} missing for uid = ${uid}`); + } } export function getVisData(data) { @@ -165,40 +158,38 @@ export function getVisData(data) { for (const [uid, v] of uidMap.entries()) { let nodeName = "", nodeObjtype = "", nodeStatus = ""; + const block = v; for (const prop in block) { - if (!block.hasOwnProperty(prop)) { - continue; + if (!block.hasOwnProperty(prop)) { + continue; + } + const val = block[prop]; + //determine whether we are looking at a property for this node OR a set of child nodes + if (Array.isArray(val) && val.length > 0 && + typeof val[0] === "object") { + // These are child nodes + for (let i = 0; i < val.length; i++) { + const fromUid = uid; //key for this map entry + const toUid = val[i].uid; + + const e = getVisFormatEdge(fromUid, toUid, prop); + edges.push(e); } - const val = block[prop]; - //determine whether we are looking at a property for this node OR a set of child nodes - if ( - Array.isArray(val) && - val.length > 0 && - typeof val[0] === "object" - ) { - // These are child nodes - for (let i = 0; i < val.length; i++) { - const fromUid = uid; //key for this map entry - const toUid = val[i].uid; - - const e = getVisFormatEdge(fromUid, toUid, prop); - edges.push(e); - } - } else { - //get properties which we need to feed to Visjs Node - if (prop === namePropNameDgraphApp) { - nodeName = val; - } - if (prop === objtypePropNameDgraphApp) { - nodeObjtype = val; - } - if (prop === nodeStatusProp || - prop === nodePhaseProp) { - nodeStatus = val; - } + } else { + //get properties which we need to feed to Visjs Node + if (prop === namePropNameDgraphApp) { + nodeName = val; + } + if (prop === objtypePropNameDgraphApp) { + nodeObjtype = val; + } + if (prop === nodeStatusProp || + prop === nodePhaseProp) { + nodeStatus = val; } + } } validateJSONData(uid, nodeName, nodeObjtype); let n = getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus); @@ -226,17 +217,17 @@ export function clearVisData(){ //colorChannelA and colorChannelB are ints ranging from 0 to 255 function colorChannelMixer(colorChannelA, colorChannelB, amountToMix){ - let channelA = colorChannelA*amountToMix; - let channelB = colorChannelB*(1-amountToMix); - return parseInt(channelA+channelB); + let channelA = colorChannelA*amountToMix; + let channelB = colorChannelB*(1-amountToMix); + return parseInt(channelA+channelB); } //rgbA and rgbB are arrays, amountToMix ranges from 0.0 to 1.0 //example (red): rgbA = [255,0,0] export function colorMixer(rgbA, rgbB, amountToMix){ - let r = colorChannelMixer(rgbA[0],rgbB[0],amountToMix); - let g = colorChannelMixer(rgbA[1],rgbB[1],amountToMix); - let b = colorChannelMixer(rgbA[2],rgbB[2],amountToMix); - return "rgb("+r+","+g+","+b+")"; + let r = colorChannelMixer(rgbA[0],rgbB[0],amountToMix); + let g = colorChannelMixer(rgbA[1],rgbB[1],amountToMix); + let b = colorChannelMixer(rgbA[2],rgbB[2],amountToMix); + return "rgb("+r+","+g+","+b+")"; } //Function to split long label names. If too long, name is split by its middle From c298c24bf8272b2f28dd77c909431a8e118133d0 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Tue, 1 Jan 2019 20:24:41 -0800 Subject: [PATCH 03/14] extend-graph-navigation - add new entity related actions --- app/Dockerfile | 4 +-- app/src/actions/actionTypes.js | 4 +++ app/src/actions/entityActions.js | 54 ++++++++++---------------------- app/src/actions/queryActions.js | 4 +-- 4 files changed, 24 insertions(+), 42 deletions(-) diff --git a/app/Dockerfile b/app/Dockerfile index f6d153c..191d5f0 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -3,9 +3,8 @@ WORKDIR /app #initially copy over just package.json (and locks, if present) for dependency installation COPY package*.json /app/ RUN yarn install -#then copy over our code for test+build +#then copy over our code for build COPY ./ /app/ -#RUN yarn test RUN yarn build #once the build-stage is complete, we can build an image for our deployment @@ -16,4 +15,5 @@ COPY --from=build-stage /app/build /usr/share/nginx/html COPY --from=build-stage /app/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build-stage /app/entrypoint.sh /etc/nginx/entrypoint.sh RUN chmod 777 /etc/nginx/entrypoint.sh +EXPOSE 80 CMD /etc/nginx/entrypoint.sh \ No newline at end of file diff --git a/app/src/actions/actionTypes.js b/app/src/actions/actionTypes.js index d189bb2..b32b7d3 100644 --- a/app/src/actions/actionTypes.js +++ b/app/src/actions/actionTypes.js @@ -2,7 +2,11 @@ export const CHANGE_QUERY = 'CHANGE_QUERY'; export const SUBMIT_QUERY = 'SUBMIT_QUERY'; export const FETCH_QUERY = 'FETCH_QUERY'; export const RECEIVE_QUERY = 'RECEIVE_QUERY'; + +export const SET_ROOT_ENTITY = 'SET_ROOT_ENTITY'; export const ADD_ENTITY_WATCH = 'ADD_ENTITY_WATCH'; export const FETCH_ENTITY = 'FETCH_ENTITY'; +export const FETCH_ENTITIES = 'FETCH_ENTITIES'; export const RECEIVE_ENTITY = 'RECEIVE_ENTITY'; + export const SHOW_NOTIFY = 'SHOW_NOTIFY'; \ No newline at end of file diff --git a/app/src/actions/entityActions.js b/app/src/actions/entityActions.js index 28bb7ec..b80c912 100644 --- a/app/src/actions/entityActions.js +++ b/app/src/actions/entityActions.js @@ -1,6 +1,11 @@ import * as types from './actionTypes'; import ApiService from "../services/ApiService"; +export const setRootEntity = uid => ({ + type: types.SET_ROOT_ENTITY, + uid +}); + export const addEntityWatch = uid => ({ type: types.ADD_ENTITY_WATCH, uid @@ -9,47 +14,20 @@ export const addEntityWatch = uid => ({ export const fetchEntity = uid => { return dispatch => { return ApiService.getEntity(uid) - .then(handleResponse) - .then(json => dispatch(receiveEntity(json[0])));//index into JSON arr to grab single entity object + //TODO:DM - can this hard-coded idx be cleaned up? + .then(json => dispatch(receiveEntity(json.objects[0]))); + }; +}; + +export const fetchEntities = uids => { + return dispatch => { + return Promise.all(uids.map(uid => + dispatch(fetchEntity(uid)) + )); }; }; export const receiveEntity = json => ({ type: types.RECEIVE_ENTITY, results: json -}); - -function handleResponse(json) { - let results = []; - let existingUids = {}; - for (let objKey in json){ - let objArr = json[objKey]; - if (objArr.length) { - objArr.forEach(obj => { - //screen out duplicate UID entries - if(!existingUids[obj.uid]){ - results.push(obj); - existingUids[obj.uid] = true; - } - }); - } - } - return results; -} - -//only update state if the objects fail lodash equality check AND -// //the component is still mounted. usually, the lifecycle methods should -// //be used directly for such things that, but in testing we're getting -// //intermittent errors that setState is being called on unmounted -// //components, without this check -// if(!_.isEqual(this.state.data, json) && this._isMounted) { -// this.setState({ -// data: json, -// waitingOnReq: false -// }); - -//logic here to transform API raw data to something usable by graph, or does that make more sense in actions? - -//also trigger periodic re-requests of data here with knowledge of period, max reqs/sec, num objs tracked? -//no, I think it still makes sense to do that in a component, probably GraphContainer, where we easily have access to -//store to see num of requests needed and can compute interval ms based on limit and num entities +}); \ No newline at end of file diff --git a/app/src/actions/queryActions.js b/app/src/actions/queryActions.js index 88c55b0..cd77a91 100644 --- a/app/src/actions/queryActions.js +++ b/app/src/actions/queryActions.js @@ -34,9 +34,9 @@ export function fetchQuery(query) { let requestPromise; if (query.includes(QSL_TAG)) { - requestPromise = ApiService.getQueryResult(query); - } else { requestPromise = ApiService.getQSLResult(query); + } else { + requestPromise = ApiService.getQueryResult(query); } return requestPromise From ecdf7db7a6e3ed046ad83318c0115f6fb118d894 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Tue, 1 Jan 2019 20:45:33 -0800 Subject: [PATCH 04/14] extend-graph-navigation - addl changes related to entity related reducer and initial state --- app/src/config/visjsConfig.js | 1 + app/src/reducers/entityReducer.js | 61 +++++++++++++++++++++++++------ app/src/reducers/initialState.js | 6 ++- app/src/utils/validate.test.js | 1 + 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/app/src/config/visjsConfig.js b/app/src/config/visjsConfig.js index bb0b9f0..6755cd6 100644 --- a/app/src/config/visjsConfig.js +++ b/app/src/config/visjsConfig.js @@ -31,6 +31,7 @@ export const options = { zoomView: true }, layout: { + randomSeed: 42, improvedLayout: true, }, }; \ No newline at end of file diff --git a/app/src/reducers/entityReducer.js b/app/src/reducers/entityReducer.js index 2caa21d..8dbe3f3 100644 --- a/app/src/reducers/entityReducer.js +++ b/app/src/reducers/entityReducer.js @@ -1,30 +1,67 @@ +import _ from 'lodash'; + import initialState from './initialState'; -import {ADD_ENTITY_WATCH, FETCH_ENTITY, RECEIVE_ENTITY} from '../actions/actionTypes'; +import { SET_ROOT_ENTITY, ADD_ENTITY_WATCH, FETCH_ENTITY, + FETCH_ENTITIES, RECEIVE_ENTITY} from '../actions/actionTypes'; +import { EdgeLabels } from '../config/appConfig'; export default function entity(state = initialState.entity, action) { - let newState, now; + let newState, now, potentialResults; switch (action.type) { + case SET_ROOT_ENTITY: + newState = { + ...state, //start with a copy of existing state + rootUid: action.uid, //and apply changes on top of existing state attrs + }; + return newState; case ADD_ENTITY_WATCH: - //start with a copy of existing state - newState = { ...state }; - //extend the uidsObj which is otherwise difficult to with variable key - //name action.uid in statement above - newState.uidsObj[action.uid] = true; + newState = { + ...state, + isWaiting: true, + }; + //extend the entity obj separately since we only want to change part of it + newState.entitiesByUid[action.uid] = {}; return newState; case FETCH_ENTITY: return action; + case FETCH_ENTITIES: + return action; case RECEIVE_ENTITY: now = +new Date(); - //project changes on top of existing state attrs - newState = { ...state, - //results: action.results, // how to add to existing results? + newState = { + ...state, latestTimestamp: now, + isWaiting: false, }; - //TODO:DM - technically I could use just 1 obj to maintain UIDs to watch as well as results, just make null until new obj arrives {val: null, timestamp: ...} - newState.results[action.results.uid] = action.results; + //extend the entity obj separately since we only want to change part of it + newState.entitiesByUid[action.results.uid] = action.results; + //build a new result obj but only update newState if it's different + potentialResults = entityWalk(newState.rootUid, newState.entitiesByUid); + if (!_.isEqual(state.results, potentialResults)) { + newState.results = potentialResults; + } return newState; default: return state; } } +const entityWalk = (rootUid, entityObj) => { + // start with root obj + let results = entityObj[rootUid]; + //walk it (recursing into all arrs) + //TODO:DM - this may not actually be recursing as deep as I want... more than 1 hop from root + for (let key in results) { + let candidate = results[key]; + //ensure that the key is an expected relationship and dealing with an array + if ((EdgeLabels.indexOf(key) > -1) && _.isArray(candidate)) { + for (let idx in candidate) { + let node = candidate[idx]; + if (entityObj[node.uid]) { + _.merge(node, entityObj[node.uid]); + } + } + } + } + return results; +}; \ No newline at end of file diff --git a/app/src/reducers/initialState.js b/app/src/reducers/initialState.js index 9110c4f..08344a4 100644 --- a/app/src/reducers/initialState.js +++ b/app/src/reducers/initialState.js @@ -4,12 +4,14 @@ export default { lastSubmitted: '', submitted: false, isWaiting: false, - results: [] + results: [], }, entity: { - uidsObj: {}, + rootUid: '', + entitiesByUid: {}, results: {}, latestTimestamp: 0, + isWaiting: false, }, notify: { msg: '', diff --git a/app/src/utils/validate.test.js b/app/src/utils/validate.test.js index 59d23d7..5e6d04f 100644 --- a/app/src/utils/validate.test.js +++ b/app/src/utils/validate.test.js @@ -1,3 +1,4 @@ +//TODO:DM - looks like the validate module isn't currently being used in the app, therefore these tests shouldn't really count UNLESS we plan to use it again import { validateIPaddress } from './validate'; it('should correctly recognize an IP addr', () => { From b21dfe14e01b43ae9198886b22d35b03c4ad0710 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Tue, 1 Jan 2019 21:10:25 -0800 Subject: [PATCH 05/14] extend-graph-navigation - graph and graph container related changes --- app/src/components/graph/Graph.js | 82 +++++++----------- app/src/components/graph/GraphContainer.js | 93 ++++++++------------ app/src/utils/graph.js | 98 ++++++++++------------ 3 files changed, 105 insertions(+), 168 deletions(-) diff --git a/app/src/components/graph/Graph.js b/app/src/components/graph/Graph.js index 6c0fec4..2782f03 100644 --- a/app/src/components/graph/Graph.js +++ b/app/src/components/graph/Graph.js @@ -12,6 +12,7 @@ import { getVisData, clearVisData, colorMixer, getLegends } from "../../utils/gr import { NodeStatusPulseColors } from "../../config/appConfig"; import './Graph.css'; +const PULSE_TIME_STEP_MS = 100; class Graph extends Component { constructor(props) { @@ -35,11 +36,9 @@ class Graph extends Component { statuses: {} }; - - this.setDetailsTab = this.setDetailsTab.bind(this); + //TODO:DM - can I mitigate need for these hard binds with => fns or something else? this.validateInputs = this.validateInputs.bind(this); this.renderGraph = this.renderGraph.bind(this); - this.renderGraphExpanded = this.renderGraphExpanded.bind(this); this.renderVisGraph = this.renderVisGraph.bind(this); this.clearNetwork = this.clearNetwork.bind(this); } @@ -49,9 +48,8 @@ class Graph extends Component { } componentWillReceiveProps(nextProps){ - if(!_.isEqual(nextProps, this.props)){ - this.clearNetwork(); - this.setState({data: nextProps.dataSet}); + if(!_.isEqual(nextProps.dataSet, this.props.dataSet)){ + this.renderGraph(nextProps.dataSet) } } @@ -69,8 +67,6 @@ class Graph extends Component { } render() { - //TODO: this is a bit awkward to have to explicitly call a render "helper" function, ideally anything critical would either be done directly in render() or earlier in lifecycle methods, etc. - this.renderGraphExpanded(this.state.data); return (
{/*Graph Visualization*/} @@ -120,54 +116,43 @@ class Graph extends Component { } renderGraph(jsonData) { - console.debug(`Got data ${JSON.stringify(jsonData)}`); - - const {nodes, edges} = getVisData(jsonData); - this._nodes = nodes; - this._edges = edges; - - this.renderVisGraph(); - } - - renderGraphExpanded(jsonData) { if (_.isEmpty(jsonData)) return; - this.nodesDataset.clear(); - this.edgesDataset.clear(); - this._nodes = []; - this._edges = []; - const {nodes, edges} = getVisData(jsonData); - const nodesNew = nodes; - const edgesNew = edges; + this._edges = edges; this._legends = getLegends(); - //Avoid adding duplicate selected node, as vis.js does not allow duplicates. - for (let i = 0; i < nodesNew.length; i++) { - this._nodes.push(nodesNew[i]); + for (let i = 0; i < nodes.length; i++) { + this._nodes.push(nodes[i]); } - this._edges.push(edgesNew); - this.renderVisGraph(); } renderVisGraph() { - this.nodesDataset = new vis.DataSet(); - for (let i = 0; i < this._nodes.length; i++) { + //determine if we have any newnodes to add to the graph dataset + for (let i = 0; i < this._nodes.length; i++) { + if(!this.nodesDataset.get(this._nodes[i].uid)){ this.nodesDataset.add(this._nodes[i]); } - this.edgesDataset = new vis.DataSet(); - for (let i = 0; i < this._edges.length; i++) { + } + //determine if we have any new edges to add to the graph dataset + for (let i = 0; i < this._edges.length; i++) { + //edge IDs are a concatenation of from and to IDs + if(!this.edgesDataset.get(this._edges[i].from + this._edges[i].to)){ this.edgesDataset.add(this._edges[i]); } - this._data = {nodes: this.nodesDataset, edges: this.edgesDataset}; + } + this._data = {nodes: this.nodesDataset, edges: this.edgesDataset}; - const container = document.getElementById("graph"); //id of div container for graph. - this._network = new vis.Network(container, this._data, options); + //id of div container for graph + const container = document.getElementById("graph"); + this._network = new vis.Network(container, this._data, options); - this.configNetwork(this._network); - //must bind call to handleColorPulse since it'll be called by browser otherwise with Window as "this" context - this.pulseIntervalHandle = setInterval(this.handleColorPulse.bind(this), 100);//TODO:DM-constantize time! + this.configNetwork(this._network); + //must bind call to handleColorPulse since it'll be called by browser + //otherwise with window as "this" context + this.pulseIntervalHandle = setInterval(this.handleColorPulse.bind(this), + PULSE_TIME_STEP_MS); } handleColorPulse() { @@ -188,17 +173,6 @@ class Graph extends Component { }); } - setDetailsTab(attributesMap) { - const attributes = []; - for (const [k, v] of attributesMap.entries()) { - if (v !== undefined && v !== "") { - const entry = ` ${k} : ${v}`; - attributes.push(entry); - } - } - this.setState({detailsTab: attributes}); - } - clearNetwork() { //cancel node pulse timers, before this._data is cleared clearInterval(this.pulseIntervalHandle); @@ -221,10 +195,10 @@ class Graph extends Component { const targetNodeUid = element.nodes[0]; const pathComponents = this.props.location.pathname.split('/'); const currentNodeUid = pathComponents[pathComponents.length - 1]; - //only update props if target node is not current node + //only add node data if target node is not current node + //TODO:DM - rather than just current node, could skip any already watched UIDs if (targetNodeUid !== currentNodeUid) { - this.clearNetwork(); - this.props.history.push('/graph/'+targetNodeUid); + this.props.entityActions.addEntityWatch(targetNodeUid); } } }); diff --git a/app/src/components/graph/GraphContainer.js b/app/src/components/graph/GraphContainer.js index 565cee8..8287190 100644 --- a/app/src/components/graph/GraphContainer.js +++ b/app/src/components/graph/GraphContainer.js @@ -1,21 +1,18 @@ import React, { Component } from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; -import _ from 'lodash'; import { withStyles } from '@material-ui/core/styles'; import Grid from '@material-ui/core/Grid'; import CircularProgress from '@material-ui/core/CircularProgress'; -import EntityDetails from '../entityDetails/EntityDetails'; -import ApiService from "../../services/ApiService"; -import Graph from './Graph'; import "./Graph.css"; +import Graph from './Graph'; +import EntityDetails from '../entityDetails/EntityDetails'; import * as entityActions from "../../actions/entityActions"; - -const DATA_FETCH_PERIOD_MS = 5000; +const FETCH_PERIOD_PER_ENTITY_MS = 2000; const styles = theme => ({ container: { @@ -35,83 +32,69 @@ const styles = theme => ({ }); class GraphContainer extends Component { - constructor(props) { - super(props); - - this.state = { - data: {}, - waitingOnReq: false - }; - this._isMounted = false; - } - componentDidMount() { - this._isMounted = true; - this.setState({waitingOnReq: true}); - this.intervalHandle = setInterval(() => this.getData(), DATA_FETCH_PERIOD_MS); - //Delay the very first call by just one scheduling cycle so that the webfont can load first + this.setRootNode(); + this.intervalHandle = setTimeout(() => this.getData(), + FETCH_PERIOD_PER_ENTITY_MS); + //delay the first call by no ms (therefore one scheduling cycle) which + //allows for webfont to be loaded before data inserted to graph setTimeout(() => this.getData(), 0); } componentDidUpdate(prevProps) { //recognize change in URL and re-issue API request as necessary if (this.props.location !== prevProps.location){ - this.setState({waitingOnReq: true}); + this.setRootNode(); this.getData(); } } componentWillUnmount() { clearInterval(this.intervalHandle); - this._isMounted = false; } - getData = () => { + setRootNode = () => { const pathComponents = this.props.location.pathname.split('/'); - const uidParam = pathComponents[pathComponents.length - 1]; + //TODO:DM - simply grabbing last param after '/' feels fragile, how to more safely verify as UID? + //could be empty string... a better default to use, if so? + const uid = pathComponents[pathComponents.length - 1]; + + this.props.entityActions.setRootEntity(uid); + this.props.entityActions.addEntityWatch(uid); - //TODO:DM - adding to watch will be redundant, but the UIDs set will enforce uniqueness, should I instead check here? - this.props.entityActions.addEntityWatch(uidParam); - this.props.entityActions.fetchEntity(uidParam); + }; - //TODO:DM - will I need to do something similar to the use of _isMounted still, for testing? - // ApiService.getEntity(uidParam) - // .then(json => { - // //only update state if the objects fail lodash equality check AND - // //the component is still mounted. usually, the lifecycle methods should - // //be used directly for such things that, but in testing we're getting - // //intermittent errors that setState is being called on unmounted - // //components, without this check - // if(!_.isEqual(this.state.data, json) && this._isMounted) { - // this.setState({ - // data: json, - // waitingOnReq: false - // }); - // } - // }); + getData = () => { + //reschedule next automatic data request while computing time value based + //on number of entities and a min time between fetches + const NUM_ENTITIES = Object.keys(this.props.entity.entitiesByUid).length; + this.intervalHandle = setTimeout(() => this.getData(), NUM_ENTITIES * FETCH_PERIOD_PER_ENTITY_MS); + //fetch all entities currently represented as keys in the store + this.props.entityActions.fetchEntities(Object.keys( + this.props.entity.entitiesByUid)); }; render() { - const { classes } = this.props; + const { classes, entity } = this.props; return (
{//selectively show the progress indicator when we're waiting for an outstanding request - this.state.waitingOnReq ? ( + entity.isWaiting ? (
- ) : null //TODO:DM - ideally want to select between spinner and graph here, but loading graph only after spinner disappears leads to graph not being correctly populated; understand why this is + ) : null } - + - +
- );//get rid of [0] idx ^ + ); } } @@ -129,11 +112,5 @@ export default connect( mapStoreToProps, mapDispatchToProps )( - withRouter( - withStyles(styles)(GraphContainer) - ) -); - -//also trigger periodic re-requests of data here with knowledge of period, max reqs/sec, num objs tracked? -//no, I think it still makes sense to do that in a component, probably GraphContainer, where we easily have access to -//store to see num of requests needed and can compute interval ms based on limit and num entities \ No newline at end of file + withRouter(withStyles(styles)(GraphContainer)) +); \ No newline at end of file diff --git a/app/src/utils/graph.js b/app/src/utils/graph.js index bcbdf6f..870c5a2 100644 --- a/app/src/utils/graph.js +++ b/app/src/utils/graph.js @@ -1,13 +1,7 @@ import _ from 'lodash'; //TODO:DM - determine what from this file can be isolated into pure util fns and what makes most sense to incorporate directly into Graph component -//This Library Includes utilities for processing dgraph json and converting to components required by vis.js. - -//NOTE- -//Below are mandatory fields for data node in the json- -//"uid", "name", "objtype", "assetid" -//JSON parsing errors are handled passively- -//Logs will show errors when issues with graph display are due to invalid JSON format. +//This Library Includes utilities for processing dgraph json and converting to components required by vis.js import { NodeIconMap, @@ -53,18 +47,11 @@ function parseDgraphData(data) { } //if this key is a relationship type (as defined in EdgeLabels) recurse to get children nodes if (EdgeLabels.indexOf(key) > -1) { - parseDgraphData(val); + _.forEach(val, (item) => parseDgraphData(item)); } }); } -function printUidMap() { - console.debug("Printing uidMap"); - for (const [k, v] of uidMap.entries()) { - console.debug(k, v); - } -} - function getNodeIcon(nodeObjtype) { if (nodeObjtype === undefined || nodeObjtype === null) { return NodeIconMap.get("default"); @@ -85,7 +72,8 @@ function getEdgeLabelShortHand(prop) { } function getVisFormatEdge(fromUid, toUid, relation) { - const e = { + return { + id: fromUid + toUid, from: fromUid, to: toUid, label: getEdgeLabelShortHand(relation), @@ -98,7 +86,6 @@ function getVisFormatEdge(fromUid, toUid, relation) { }, arrows: "to" }; - return e; } function getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus) { @@ -111,7 +98,7 @@ function getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus) { titleParam = nodeName; } - //some names are too large to render correctly in hierarchy, split them by middle dash across 2 lines of text + //some names are too large to render elegantly, split them by middle dash, if present, across 2 lines of text nodeName = nameSplitter(nodeName); const color = NodeStatusColorMap.get(nodeStatus || NODE_DEFAULT_STR); @@ -151,53 +138,52 @@ function validateJSONData(uid, nodeName, nodeObjtype) { } export function getVisData(data) { + let existingUids = {}; clearVisData(); - _.forOwn(data, (entity, key) => { - parseDgraphData(entity); - printUidMap(); - - for (const [uid, v] of uidMap.entries()) { - let nodeName = "", nodeObjtype = "", nodeStatus = ""; + parseDgraphData(data); - const block = v; + for (const [uid, v] of uidMap.entries()) { + let nodeName = "", nodeObjtype = "", nodeStatus = ""; + const block = v; - for (const prop in block) { - if (!block.hasOwnProperty(prop)) { - continue; + for (const prop in block) { + if (!block.hasOwnProperty(prop)) { + continue; + } + const val = block[prop]; + //determine whether we are looking at a property for this node OR a set of child nodes + if (Array.isArray(val) && val.length > 0 && + typeof val[0] === "object") { + // These are child nodes + for (let i = 0; i < val.length; i++) { + const fromUid = uid; //key for this map entry + const toUid = val[i].uid; + + const e = getVisFormatEdge(fromUid, toUid, prop); + edges.push(e); } - const val = block[prop]; - //determine whether we are looking at a property for this node OR a set of child nodes - if (Array.isArray(val) && val.length > 0 && - typeof val[0] === "object") { - // These are child nodes - for (let i = 0; i < val.length; i++) { - const fromUid = uid; //key for this map entry - const toUid = val[i].uid; - - const e = getVisFormatEdge(fromUid, toUid, prop); - edges.push(e); - } - } else { - //get properties which we need to feed to Visjs Node - if (prop === namePropNameDgraphApp) { - nodeName = val; - } - if (prop === objtypePropNameDgraphApp) { - nodeObjtype = val; - } - if (prop === nodeStatusProp || - prop === nodePhaseProp) { - nodeStatus = val; - } + } else { + //get properties which we need to feed to Visjs Node + if (prop === namePropNameDgraphApp) { + nodeName = val; + } + if (prop === objtypePropNameDgraphApp) { + nodeObjtype = val; + } + if (prop === nodeStatusProp || + prop === nodePhaseProp) { + nodeStatus = val; } } - validateJSONData(uid, nodeName, nodeObjtype); - let n = getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus); + } + validateJSONData(uid, nodeName, nodeObjtype); + let n = getVisFormatNode(uid, nodeName, nodeObjtype, nodeStatus); + if(!existingUids[n.uid]){ nodes.push(n); + existingUids[n.uid] = true; } - }); + } return {nodes, edges}; - } export function getLegends(){ From cedc7f33fd1568930425b180ef0efea2cf12e737 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Tue, 1 Jan 2019 21:17:55 -0800 Subject: [PATCH 06/14] extend-graph-navigation - updating tests so as to wrap GraphContainer with store provider --- app/src/components/app/App.test.js | 2 +- .../components/graph/GraphContainer.test.js | 37 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/app/src/components/app/App.test.js b/app/src/components/app/App.test.js index 46f9dee..f30f625 100644 --- a/app/src/components/app/App.test.js +++ b/app/src/components/app/App.test.js @@ -4,8 +4,8 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { MemoryRouter } from 'react-router-dom'; import { mount, shallow } from 'enzyme'; -import configureStore from '../../store/configureStore'; import App from './App'; +import configureStore from '../../store/configureStore'; //next import will load envVars from local override of app/public/conf.js import '../../../public/conf'; diff --git a/app/src/components/graph/GraphContainer.test.js b/app/src/components/graph/GraphContainer.test.js index acc47d7..df549b5 100644 --- a/app/src/components/graph/GraphContainer.test.js +++ b/app/src/components/graph/GraphContainer.test.js @@ -1,25 +1,26 @@ import React from 'react'; -import {render, unmountComponentAtNode} from 'react-dom'; -import {MemoryRouter} from 'react-router-dom'; -import {mount, shallow} from 'enzyme'; +import { Provider } from 'react-redux'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { MemoryRouter } from 'react-router-dom'; +import { mount } from 'enzyme'; import GraphContainer from './GraphContainer'; +import configureStore from '../../store/configureStore'; //next import will load envVars from local override of app/public/conf.js import '../../../public/conf'; jest.useFakeTimers(); const div = document.createElement('div'); - -it('shallow renders graph container', () => { - shallow(); -}); +const store = configureStore(); it('deep renders graph container', () => { render( - - - , div); + + + + + , div); unmountComponentAtNode(div); }); @@ -29,9 +30,11 @@ it('deep renders graph container while making async request', (done) => { Promise.resolve(new Response(JSON.stringify(MOCK_RESP), {status:200}))); //render component with path which will cause data fetch render( - - - , div); + + + + + , div); //force timers to complete, so as to trigger request jest.runOnlyPendingTimers(); //ensure that fetch was called at least once @@ -42,9 +45,11 @@ it('deep renders graph container while making async request', (done) => { it('shows a spinner during outstanding request', () => { const wrapper = mount( - - - ); + + + + + ); wrapper.setState({waitingOnReq: true}); expect(wrapper.find('CircularProgress').length).toBe(1); }); From b09ace217c52eec514f5ec4db6a0ae1f65f0bd0b Mon Sep 17 00:00:00 2001 From: David Masselink Date: Wed, 2 Jan 2019 14:07:31 -0800 Subject: [PATCH 07/14] extend-graph-navigation - adding use of splitter to graph container, modeled on recent changes to results/results list --- app/src/components/graph/GraphContainer.js | 14 +++++--------- app/src/components/results/Results.js | 14 +++++--------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/app/src/components/graph/GraphContainer.js b/app/src/components/graph/GraphContainer.js index 412022e..a95a71d 100644 --- a/app/src/components/graph/GraphContainer.js +++ b/app/src/components/graph/GraphContainer.js @@ -4,8 +4,8 @@ import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withStyles } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; import CircularProgress from '@material-ui/core/CircularProgress'; +import SplitterLayout from 'react-splitter-layout'; import "./Graph.css"; import Graph from './Graph'; @@ -92,14 +92,10 @@ class GraphContainer extends Component {
) : null } - - - - - - - - + + + + ); } diff --git a/app/src/components/results/Results.js b/app/src/components/results/Results.js index 48f3a50..7934dcc 100644 --- a/app/src/components/results/Results.js +++ b/app/src/components/results/Results.js @@ -3,16 +3,14 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; import CircularProgress from '@material-ui/core/CircularProgress'; import Typography from '@material-ui/core/Typography'; import SplitterLayout from 'react-splitter-layout'; +import ResultList from './ResultList'; import EntityDetails from '../entityDetails/EntityDetails'; import * as apiCfg from '../../config/apiConfig'; import * as queryActions from '../../actions/queryActions'; -import ResultList from './ResultList'; - const styles = theme => ({ progress: { @@ -81,12 +79,10 @@ class Results extends Component { ) : ( - - - - - - + + + + )} ); From 8fbaf88f4f7529dda2f421d9dbd0adba696d5c2e Mon Sep 17 00:00:00 2001 From: David Masselink Date: Wed, 2 Jan 2019 14:24:38 -0800 Subject: [PATCH 08/14] extend-graph-navigation - clean-up home component, re-center headers --- app/src/components/home/Home.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/components/home/Home.js b/app/src/components/home/Home.js index 2c11210..96c30ce 100644 --- a/app/src/components/home/Home.js +++ b/app/src/components/home/Home.js @@ -6,10 +6,10 @@ import { withRouter } from 'react-router-dom'; import { withStyles } from '@material-ui/core/styles'; import TextField from '@material-ui/core/TextField'; +import './Home.css'; +import logo from './map.png'; import { ENTER_KEYCODE, ENTER_KEYSTR } from "../../config/appConfig"; import * as queryActions from '../../actions/queryActions'; -import logo from './map.png'; -import './Home.css'; const styles = theme => ({ container: { @@ -42,20 +42,19 @@ class Home extends Component { }; render() { + const { classes, query } = this.props; return (
-
-

Welcome to Kubernetes Application Topology Browser

-

K-Atlas Browser

-
-
+

Welcome to Kubernetes Application Topology Browser

+

K-Atlas Browser

+
From 8a5f5c670f6a327122c2509565763e9f98d370c0 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Mon, 7 Jan 2019 14:33:32 -0800 Subject: [PATCH 09/14] extend-graph-navigation - incorporating review feedback and some general clean-up --- app/src/actions/entityActions.js | 4 +--- app/src/actions/queryActions.js | 28 +++++++++++----------- app/src/components/graph/GraphContainer.js | 26 ++++++++++---------- app/src/reducers/entityReducer.js | 28 ++++++++++++++-------- app/src/reducers/queryReducer.js | 11 +++------ 5 files changed, 50 insertions(+), 47 deletions(-) diff --git a/app/src/actions/entityActions.js b/app/src/actions/entityActions.js index b80c912..3cc5314 100644 --- a/app/src/actions/entityActions.js +++ b/app/src/actions/entityActions.js @@ -21,9 +21,7 @@ export const fetchEntity = uid => { export const fetchEntities = uids => { return dispatch => { - return Promise.all(uids.map(uid => - dispatch(fetchEntity(uid)) - )); + uids.map(uid => dispatch(fetchEntity(uid))); }; }; diff --git a/app/src/actions/queryActions.js b/app/src/actions/queryActions.js index cd77a91..830951b 100644 --- a/app/src/actions/queryActions.js +++ b/app/src/actions/queryActions.js @@ -51,19 +51,19 @@ export const receiveQuery = json => ({ }); function handleResponse(json) { - let results = []; - let existingUids = {}; - for (let objKey in json){ - let objArr = json[objKey]; - if (objArr.length) { - objArr.forEach(obj => { - //screen out duplicate UID entries - if(!existingUids[obj.uid]){ - results.push(obj); - existingUids[obj.uid] = true; - } - }); - } + let results = []; + let existingUids = {}; + for (let objKey in json){ + let objArr = json[objKey]; + if (objArr.length) { + objArr.forEach(obj => { + //screen out duplicate UID entries + if(!existingUids[obj.uid]){ + results.push(obj); + existingUids[obj.uid] = true; + } + }); } - return results; } + return results; +} diff --git a/app/src/components/graph/GraphContainer.js b/app/src/components/graph/GraphContainer.js index a95a71d..01883e4 100644 --- a/app/src/components/graph/GraphContainer.js +++ b/app/src/components/graph/GraphContainer.js @@ -41,15 +41,13 @@ const styles = theme => ({ class GraphContainer extends Component { componentDidMount() { this.setRootNode(); - this.intervalHandle = setTimeout(() => this.getData(), - FETCH_PERIOD_PER_ENTITY_MS); - //delay the first call by no ms (therefore one scheduling cycle) which - //allows for webfont to be loaded before data inserted to graph - setTimeout(() => this.getData(), 0); + //run first data acquisition event immediately, maintain handle for + //cancellation purpose + this.intervalHandle = setTimeout(() => this.getDataInterval(), 0); } componentDidUpdate(prevProps) { - //recognize change in URL and re-issue API request as necessary + //recognize change in URL and re-issue API request in that case if (this.props.location !== prevProps.location){ this.setRootNode(); this.getData(); @@ -60,7 +58,7 @@ class GraphContainer extends Component { clearInterval(this.intervalHandle); } - setRootNode = () => { + setRootNode() { const pathComponents = this.props.location.pathname.split('/'); //TODO:DM - simply grabbing last param after '/' feels fragile, how to more safely verify as UID? //could be empty string... a better default to use, if so? @@ -68,18 +66,22 @@ class GraphContainer extends Component { this.props.entityActions.setRootEntity(uid); this.props.entityActions.addEntityWatch(uid); + } - }; - - getData = () => { + getDataInterval() { + this.getData(); //reschedule next automatic data request while computing time value based //on number of entities and a min time between fetches const NUM_ENTITIES = Object.keys(this.props.entity.entitiesByUid).length; - this.intervalHandle = setTimeout(() => this.getData(), NUM_ENTITIES * FETCH_PERIOD_PER_ENTITY_MS); + this.intervalHandle = setTimeout(() => this.getDataInterval(), + NUM_ENTITIES * FETCH_PERIOD_PER_ENTITY_MS); + } + + getData() { //fetch all entities currently represented as keys in the store this.props.entityActions.fetchEntities(Object.keys( this.props.entity.entitiesByUid)); - }; + } render() { const { classes, entity } = this.props; diff --git a/app/src/reducers/entityReducer.js b/app/src/reducers/entityReducer.js index 8dbe3f3..cc9a71c 100644 --- a/app/src/reducers/entityReducer.js +++ b/app/src/reducers/entityReducer.js @@ -51,17 +51,25 @@ const entityWalk = (rootUid, entityObj) => { let results = entityObj[rootUid]; //walk it (recursing into all arrs) //TODO:DM - this may not actually be recursing as deep as I want... more than 1 hop from root - for (let key in results) { + _.forOwn(results, (val, key) => { let candidate = results[key]; - //ensure that the key is an expected relationship and dealing with an array - if ((EdgeLabels.indexOf(key) > -1) && _.isArray(candidate)) { - for (let idx in candidate) { - let node = candidate[idx]; - if (entityObj[node.uid]) { - _.merge(node, entityObj[node.uid]); - } - } + if ((EdgeLabels.indexOf(key) > -1)){ + entityWalkHelper(candidate, entityObj); } - } + }); return results; +}; + +const entityWalkHelper = (candidate, entityObj) => { + //ensure that the key is an expected relationship and the val is an array + if (_.isArray(candidate)) { + _.forEach(candidate, (node) => { + if (entityObj[node.uid]) { + _.merge(node, entityObj[node.uid]); + } + }); + } else { + //object or string + //how to recurse here? + } }; \ No newline at end of file diff --git a/app/src/reducers/queryReducer.js b/app/src/reducers/queryReducer.js index 4e84e67..056c74b 100644 --- a/app/src/reducers/queryReducer.js +++ b/app/src/reducers/queryReducer.js @@ -6,17 +6,14 @@ export default function query(state = initialState.query, action) { switch (action.type) { case CHANGE_QUERY: newState = { + ...state, current: action.query, - lastSubmitted: state.lastSubmitted, submitted: false, - isWaiting: state.isWaiting, - results: state.results, }; return newState; case SUBMIT_QUERY: newState = { - current: state.current, - lastSubmitted: state.current, + ...state, submitted: true, isWaiting: true, results: [], //new array to clear out old results upon new submission @@ -26,9 +23,7 @@ export default function query(state = initialState.query, action) { return action; case RECEIVE_QUERY: newState = { - current: state.current, - lastSubmitted: state.lastSubmitted, - submitted: state.submitted, + ...state, isWaiting: false, results: action.results }; From efc670be284f95e3b5d23b590e7a18770431acd0 Mon Sep 17 00:00:00 2001 From: David Masselink Date: Mon, 7 Jan 2019 14:44:04 -0800 Subject: [PATCH 10/14] extend-graph-navigation - merge fixes, updated alpha channel in logo png, refined relationships rendered --- app/src/actions/queryActions.js | 4 ++-- app/src/components/home/map.png | Bin 21123 -> 22813 bytes app/src/config/appConfig.js | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/actions/queryActions.js b/app/src/actions/queryActions.js index c7083e9..f5e17af 100644 --- a/app/src/actions/queryActions.js +++ b/app/src/actions/queryActions.js @@ -1,9 +1,9 @@ -import ApiService from "../services/ApiService"; import * as types from './actionTypes'; import * as notifyActions from './notifyActions'; //Use of app history here so that route navigations can be occur in actions, //which are otherwise not wrappable withRouter import history from '../history'; +import ApiService from "../services/ApiService"; import { QUERY_LEN_ERR } from '../utils/errors'; //TODO:DM - is there a better place to define router related consts? @@ -20,7 +20,7 @@ export const changeQuery = str => ({ const submitQueryAction = () => ({ type: types.SUBMIT_QUERY, -}) +}); export function submitQuery(query) { return dispatch => { diff --git a/app/src/components/home/map.png b/app/src/components/home/map.png index 18a56620bf0ea8483863e30ee761916c26d29956..20dd78ba400acbebc20e5ca918aa89d7caf458a0 100644 GIT binary patch literal 22813 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 diff --git a/app/src/config/appConfig.js b/app/src/config/appConfig.js index 5522d41..7b767b1 100644 --- a/app/src/config/appConfig.js +++ b/app/src/config/appConfig.js @@ -34,7 +34,7 @@ export const NodeStatusColorMap = new Map([ ['Running', '#25e293'], ['Bound', '#25e293'], //concerning, risky statuses - yellow - ['Terminating', '#e2cf25'], //this color doesn't matter since it will be overwritten thru pulsing... but probably good for the legend + ['Terminating', '#e2cf25'], //this color will be overwritten thru pulsing, but is still needed for the legend //error, fault statuses - red ['Stopped', '#e2254e'], ]); @@ -50,5 +50,6 @@ export const EdgeColorMap = new Map([ export const EdgeLabels = ['belongs_to', 'binds', 'claims', 'contains', 'controlled_by', 'has', 'is_bound_to', 'routes_traffic', 'runs', - //relationship types used after dgraph metadata changes - 'cluster', 'namespace', 'owner', 'nodename', '~cluster', '~owner', '~namespace']; + //relationship types used after dgraph metadata changes, not currently using + //'~cluster', '~namesapce' since they often have hundreds of children + 'cluster', 'namespace', 'owner', 'nodename', '~owner']; From 4fca129ff03436d568fd6a78786bcf2c1698c5bd Mon Sep 17 00:00:00 2001 From: David Masselink Date: Mon, 7 Jan 2019 16:43:44 -0800 Subject: [PATCH 11/14] extend-graph-navigation - get recursive navigation working with entity reucer; clean up a notifier test --- app/src/components/graph/Graph.js | 2 ++ app/src/components/notifier/Notifier.test.js | 2 -- app/src/reducers/entityReducer.js | 38 ++++++++++---------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/src/components/graph/Graph.js b/app/src/components/graph/Graph.js index 2782f03..31a980a 100644 --- a/app/src/components/graph/Graph.js +++ b/app/src/components/graph/Graph.js @@ -199,6 +199,8 @@ class Graph extends Component { //TODO:DM - rather than just current node, could skip any already watched UIDs if (targetNodeUid !== currentNodeUid) { this.props.entityActions.addEntityWatch(targetNodeUid); + //and immediately attempt to fetch for the indicated UID + this.props.entityActions.fetchEntity(targetNodeUid); } } }); diff --git a/app/src/components/notifier/Notifier.test.js b/app/src/components/notifier/Notifier.test.js index 847d6cb..5d7d32c 100644 --- a/app/src/components/notifier/Notifier.test.js +++ b/app/src/components/notifier/Notifier.test.js @@ -133,8 +133,6 @@ it('Notifier can open snackbar for errors returned from httpService', () => { //check for expected state of the store const nowStore = store.getState(); - console.log('nowstore.notify.type=' + nowStore.notify.type); - console.log('nowstore.notify.msg=' + nowStore.notify.msg); expect(nowStore.notify.msg).not.toEqual(''); expect(nowStore.notify.timestamp).not.toEqual(0); let timeDiff = +new Date() - nowStore.notify.timestamp; diff --git a/app/src/reducers/entityReducer.js b/app/src/reducers/entityReducer.js index cc9a71c..70f0d42 100644 --- a/app/src/reducers/entityReducer.js +++ b/app/src/reducers/entityReducer.js @@ -49,27 +49,25 @@ export default function entity(state = initialState.entity, action) { const entityWalk = (rootUid, entityObj) => { // start with root obj let results = entityObj[rootUid]; - //walk it (recursing into all arrs) - //TODO:DM - this may not actually be recursing as deep as I want... more than 1 hop from root - _.forOwn(results, (val, key) => { - let candidate = results[key]; - if ((EdgeLabels.indexOf(key) > -1)){ - entityWalkHelper(candidate, entityObj); - } - }); + let encounteredUids = {}; + encounteredUids[rootUid] = true; + + entityWalkHelper(results, entityObj, encounteredUids); return results; }; -const entityWalkHelper = (candidate, entityObj) => { - //ensure that the key is an expected relationship and the val is an array - if (_.isArray(candidate)) { - _.forEach(candidate, (node) => { - if (entityObj[node.uid]) { - _.merge(node, entityObj[node.uid]); - } - }); - } else { - //object or string - //how to recurse here? - } +const entityWalkHelper = (results, entityObj, encounteredUids) => { + _.forOwn(results, (childrenCandidate, key) => { + if ((EdgeLabels.indexOf(key) > -1) && _.isArray(childrenCandidate)){ + _.forEach(childrenCandidate, node => { + if (node.uid && entityObj[node.uid] && !encounteredUids[node.uid]) { + _.assign(node, entityObj[node.uid]); + encounteredUids[node.uid] = true; + } + //recurse thru object if children are present, important to do this + //after this object is augmented so we won't later overwrite + entityWalkHelper(node, entityObj, encounteredUids); + }); + } + }); }; \ No newline at end of file From e460a0fe351e02d26eba5fa2105ef6b952ce47ff Mon Sep 17 00:00:00 2001 From: David Masselink Date: Mon, 7 Jan 2019 17:00:55 -0800 Subject: [PATCH 12/14] extend-graph-navigation - fixing broken test cases which somehow still ran locally --- app/src/components/notifier/Notifier.test.js | 5 +---- .../services/{httpService.test.js => HttpService.test.js} | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) rename app/src/services/{httpService.test.js => HttpService.test.js} (97%) diff --git a/app/src/components/notifier/Notifier.test.js b/app/src/components/notifier/Notifier.test.js index 5d7d32c..dd19792 100644 --- a/app/src/components/notifier/Notifier.test.js +++ b/app/src/components/notifier/Notifier.test.js @@ -4,17 +4,14 @@ import { MemoryRouter } from 'react-router-dom'; import { mount } from 'enzyme'; import fetchMock from 'fetch-mock'; -import configureStore from '../../store/configureStore'; import {AutoHideDuration} from './Notifier'; import App from '../app/App'; -import {HttpService} from '../../services/httpService'; +import {HttpService} from '../../services/HttpService'; import {QUERY_LEN_ERR} from "../../utils/errors"; //Use the real store rather than mock Store to keep consistent with the same that is used by HttpService. import store from '../../store.js'; -const div = document.createElement('div'); - function sleep (time) { return new Promise((resolve) => setTimeout(resolve, time)); } diff --git a/app/src/services/httpService.test.js b/app/src/services/HttpService.test.js similarity index 97% rename from app/src/services/httpService.test.js rename to app/src/services/HttpService.test.js index 6a42410..cfee660 100644 --- a/app/src/services/httpService.test.js +++ b/app/src/services/HttpService.test.js @@ -1,5 +1,5 @@ import fetchMock from 'fetch-mock'; -import {HttpService} from './httpService'; +import {HttpService} from './HttpService'; it('Returns 200 for GET request, then response is as expected', () => { From b4ce6145fc8d6bf4792811c31f70be9c92a6c8bb Mon Sep 17 00:00:00 2001 From: David Masselink Date: Fri, 11 Jan 2019 11:19:22 -0800 Subject: [PATCH 13/14] fix-notifier-tests by changing from looking for an autogenerated CSS class to a node ID under our control * also leaving a TODO comment for @ssmails --- app/src/components/notifier/Notifier.test.js | 44 ++++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/app/src/components/notifier/Notifier.test.js b/app/src/components/notifier/Notifier.test.js index dd19792..2a0ad67 100644 --- a/app/src/components/notifier/Notifier.test.js +++ b/app/src/components/notifier/Notifier.test.js @@ -16,9 +16,6 @@ function sleep (time) { return new Promise((resolve) => setTimeout(resolve, time)); } -//Using hostNodes hack instead of wrapper.find('.Notifier-root-120').length - refer https://github.com/airbnb/enzyme/issues/1253 -//expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); - it('Notifier does not open snackbar for valid query', () => { const SEARCH_STR = 'foobar'; @@ -30,8 +27,8 @@ it('Notifier does not open snackbar for valid query', () => { ); - //Check for class name of Notifier Snack Bar. Initially not present. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Initially not present. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); let input = wrapper.find('input').last(); input.simulate('change', { target: { value: SEARCH_STR}}); @@ -42,8 +39,8 @@ it('Notifier does not open snackbar for valid query', () => { const nowStore = store.getState(); expect(nowStore.notify.msg).toEqual(''); - //Check for class name of Notifier Snack Bar. Present now. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Present now. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); }); it('Notifier can open snackbar for empty query', () => { @@ -57,8 +54,8 @@ it('Notifier can open snackbar for empty query', () => { ); - //Check for class name of Notifier Snack Bar. Initially not present. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Initially not present. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); let input = wrapper.find('input').last(); input.simulate('change', { target: { value: SEARCH_STR}}); @@ -69,8 +66,8 @@ it('Notifier can open snackbar for empty query', () => { const nowStore = store.getState(); expect(nowStore.notify.msg).toEqual(QUERY_LEN_ERR); - //Check for class name of Notifier Snack Bar. Present now. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(1); + //Check for id of Notifier Snack Bar. Present now. + expect(wrapper.find('#snackbar-message-id').length).toEqual(1); }); it('Notifier can open snackbar for short query', () => { @@ -84,8 +81,8 @@ it('Notifier can open snackbar for short query', () => { ); - //Check for class name of Notifier Snack Bar. Initially not present. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Initially not present. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); let input = wrapper.find('input').last(); input.simulate('change', { target: { value: SEARCH_STR}}); @@ -96,8 +93,8 @@ it('Notifier can open snackbar for short query', () => { const nowStore = store.getState(); expect(nowStore.notify.msg).toEqual(QUERY_LEN_ERR); - //Check for class name of Notifier Snack Bar. Present now. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(1); + //Check for id of Notifier Snack Bar. Present now. + expect(wrapper.find('#snackbar-message-id').length).toEqual(1); }); it('Notifier can open snackbar for errors returned from httpService', () => { @@ -109,8 +106,8 @@ it('Notifier can open snackbar for errors returned from httpService', () => { ); - //Check for class name of Notifier Snack Bar. Initially not present. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Initially not present. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); let dummyUrl = "http://katlas.com/v1/qsl"; let dummyParams = {qslstring: 'Cluster'}; @@ -152,8 +149,8 @@ it('Notifier can close snackbar after AutoHideDuration', () => { ); - //Check for class name of Notifier Snack Bar. Initially not present. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Initially not present. + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); input = wrapper.find('input').last(); input.simulate('change', { target: { value: SEARCH_STR}}); @@ -164,12 +161,13 @@ it('Notifier can close snackbar after AutoHideDuration', () => { const nowStore = store.getState(); expect(nowStore.notify.msg).toEqual(QUERY_LEN_ERR); - //Check for class name of Notifier Snack Bar. Present now. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(1); + //Check for id of Notifier Snack Bar. Present now. + expect(wrapper.find('#snackbar-message-id').length).toEqual(1); sleep(AutoHideDuration).then(() => { - //Check for class name of Notifier Snack Bar. Not present now as we are over the AutoHideDuration. - expect(wrapper.find('.Notifier-root-120').hostNodes().length).toEqual(0); + //Check for id of Notifier Snack Bar. Not present now as we are over the AutoHideDuration. + //TODO:SS - this expectation isn't actually being executed during the lifespan of the test, if I introduce the "done()" async feature of the test, it exceeds jest timeout. overall, I think you'll need to fix the test to simulate the time rather than actually sleep for it + expect(wrapper.find('#snackbar-message-id').length).toEqual(0); }); }); From 94f3a1f2a9adf0f9a2c4407e423a4a4d0ce5cd1b Mon Sep 17 00:00:00 2001 From: David Masselink Date: Wed, 20 Mar 2019 19:09:47 -0700 Subject: [PATCH 14/14] #140 - make collapsable drawer default to closed, but with a larger, more obvious, control mechanism; also fix bug #145 by including when necessary in graph URL --- app/src/components/app/App.js | 3 ++ app/src/components/errorPage/ErrorPage.css | 14 +++++++++ app/src/components/errorPage/ErrorPage.js | 28 ++++++++++++++++++ .../errorPage/katlas-logo-blue-300px.png | Bin 0 -> 16264 bytes app/src/components/graph/GraphContainer.js | 2 +- app/src/components/results/ResultList.js | 6 +++- app/src/components/results/Results.css | 6 ++++ app/src/components/results/Results.js | 4 ++- app/src/components/results/grabIcon.png | Bin 0 -> 743 bytes 9 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 app/src/components/errorPage/ErrorPage.css create mode 100644 app/src/components/errorPage/ErrorPage.js create mode 100644 app/src/components/errorPage/katlas-logo-blue-300px.png create mode 100644 app/src/components/results/Results.css create mode 100644 app/src/components/results/grabIcon.png diff --git a/app/src/components/app/App.js b/app/src/components/app/App.js index 6f12938..bc5c416 100644 --- a/app/src/components/app/App.js +++ b/app/src/components/app/App.js @@ -6,6 +6,7 @@ import Home from "../home/Home"; import Results from "../results/Results"; import GraphContainer from "../graph/GraphContainer"; import Notifier from '../notifier/Notifier'; +import ErrorPage from '../errorPage/ErrorPage'; export default class App extends Component { render() { @@ -17,6 +18,8 @@ export default class App extends Component { + {/* catch-all route in case of no earlier match */} +
); diff --git a/app/src/components/errorPage/ErrorPage.css b/app/src/components/errorPage/ErrorPage.css new file mode 100644 index 0000000..7872022 --- /dev/null +++ b/app/src/components/errorPage/ErrorPage.css @@ -0,0 +1,14 @@ +.Error { + min-height: 5vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 1vmin); +} + +.Error-logo-full { + height: auto; + width: auto; + margin: 20px; +} \ No newline at end of file diff --git a/app/src/components/errorPage/ErrorPage.js b/app/src/components/errorPage/ErrorPage.js new file mode 100644 index 0000000..6371303 --- /dev/null +++ b/app/src/components/errorPage/ErrorPage.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { withStyles } from '@material-ui/core/styles'; + +import './ErrorPage.css'; +import logo from './katlas-logo-blue-300px.png'; + +const styles = () => ({ + title: { + textAlign: 'center' + } +}); + +class ErrorPage extends React.Component { + render() { + const { classes } = this.props; + return ( +
+
+

Something appears to have gone wrong. Please retry your operation.

+
+ logo +
+ ); + } +} + +export default withStyles(styles)(withRouter(ErrorPage)); diff --git a/app/src/components/errorPage/katlas-logo-blue-300px.png b/app/src/components/errorPage/katlas-logo-blue-300px.png new file mode 100644 index 0000000000000000000000000000000000000000..f1ec7e5b71aa508ffb9d13532a735d3f57808dc4 GIT binary patch literal 16264 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 wes1Y}KU4iXE!+Q}rlATwbhF#L)kL0la|x6#xJL literal 0 HcmV?d00001 diff --git a/app/src/components/graph/GraphContainer.js b/app/src/components/graph/GraphContainer.js index d412b0a..752588d 100644 --- a/app/src/components/graph/GraphContainer.js +++ b/app/src/components/graph/GraphContainer.js @@ -107,7 +107,7 @@ class GraphContainer extends Component {
) : null } - + diff --git a/app/src/components/results/ResultList.js b/app/src/components/results/ResultList.js index 432bf95..9b0c3da 100644 --- a/app/src/components/results/ResultList.js +++ b/app/src/components/results/ResultList.js @@ -15,6 +15,7 @@ import Tooltip from '@material-ui/core/Tooltip'; import { ResultPaginationActionsWrapped } from './ResultPaginationActions'; import { getQueryLayout, rowCellsFromLayout } from './layoutComposer'; import { addResourceIdFilterQSL } from "../../utils/validate"; +import history from '../../history'; // Customized table cell theme export const CustomTableCell = withStyles(theme => ({ @@ -115,9 +116,12 @@ class ResultList extends Component { tableRows = query.results.map((item, idx) => { let cells = rowCellsFromLayout(item, layout); + //build url to use for QSL in graph action; include basename, if applicable + let rowUrl = (history.basename ? history.basename : '' ) + '/graph/' + + addResourceIdFilterQSL(query.current, item.resourceid); cells.unshift( - + {'\uf0e8'} diff --git a/app/src/components/results/Results.css b/app/src/components/results/Results.css new file mode 100644 index 0000000..204af98 --- /dev/null +++ b/app/src/components/results/Results.css @@ -0,0 +1,6 @@ +.splitter-layout > .layout-splitter { + width: 12px; + background-image: url("./grabIcon.png"); + background-position: center; + background-repeat: no-repeat; +} \ No newline at end of file diff --git a/app/src/components/results/Results.js b/app/src/components/results/Results.js index 520b476..94a5541 100644 --- a/app/src/components/results/Results.js +++ b/app/src/components/results/Results.js @@ -16,6 +16,8 @@ import EntityDetails from '../entityDetails/EntityDetails'; import * as queryActions from '../../actions/queryActions'; import { getQueryParam } from '../../utils/url'; +import './Results.css'; + const styles = theme => ({ progress: { margin: theme.spacing.unit * 2 @@ -144,7 +146,7 @@ class Results extends Component { ) : ( ;VkfoEM{Qf z76xHPhFNnYfC?frLn2Bde0{8v^KP? zWt5Z@Sn2DRmzV368|&p4rRy77T3YHG80i}s=>k>g7FXt#Bv$C=6)QswftllyTAW;z zSx}OhpQivaH!&%{w8U0P31kr*K-^i9nTD__uNdkrpa=CqGWv#k2KsQbfm&@qqE`Mz znW;dVLFU^T+JIG}h(YbK(Fa+MxY z&Uzho5SVvUWw*v`hyL0&nco~;><5=`40x?+{_%lk{=_+76*?*c{P#W+yyVR4sI>IR z8;i+hulYq~Zk(yPf2PJ-QtWv8b#85w)o{4d zQxZ8X;JT^$)HKn@yH$>F4Er^^*6u)%??;*1jU!%_R8t9+>v| zV84_9yHEf1pDg^h=iTm28vlMVHpWlsUov6(-pC(0$0k@Q*qOFB{jw